Java-秘籍第四版-全-

Java 秘籍第四版(全)

原文:zh.annas-archive.org/md5/0f97e455a02e6f168767c004952156f0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

像任何使用最多的编程语言一样,Java 有其反对者、支持者、问题、怪癖¹ 和一个学习曲线。Java Cookbook 的目标是帮助 Java 开发者迅速掌握 Java 开发的一些最重要的部分。我专注于标准 API 和一些第三方 API,但也不吝涉及语言问题。

这是本书的第四版,经过许多人的共同努力以及 Java 这两个十年中经历的无数变化而形成。对 Java 历史感兴趣的读者可以参考 附录 A。

Java 11 是当前的长期支持版本,但 Java 12 和 13 已经发布。Java 14 正在提前访问,并计划在本书第四版发布当天最终发布。每六个月发布一次的新发布节奏可能对 Oracle 的 Java SE 开发团队和以 Java 为关键词的新闻网站来说是个好消息,但对于 Java 书籍作者来说,“可能会增加一些额外的工作”,因为书籍的修订周期通常比 Java 现在的发布周期长!Java 9 是本书上一版之后发布的,是一个破坏性的发布,是很长时间以来第一个破坏向后兼容性的发布,主要是 Java 模块系统。本书中的所有内容都假定在任何仍在使用的 JVM 上都可以工作。任何人都不应该再使用 Java 7(或之前的版本!)做任何事情,并且任何人都不应该在 Java 8 上进行新的开发。如果你还在使用,现在是时候转变了!

此次修订的目标是保持本书与所有这些变化的同步。虽然剔除了大量旧材料,我还添加了诸如模块和交互式 JShell 等新特性的信息,并在此过程中更新了大量其他信息。

本书适合谁?

我假设你已经掌握了 Java 的基础知识。我不会告诉你如何 println 一个字符串,也不会告诉你如何编写一个继承另一个类和/或实现接口的类。我假设你已经学过像 Learning Tree 的介绍 或者像 Head First JavaLearning JavaJava in a Nutshell(O’Reilly)这样的入门书籍。不过,第一章 涵盖了一些你可能不太熟悉但理解后续内容所必需的技术。欢迎随意查阅!书籍的印刷版和电子版都有大量交叉引用。

本书内容包括什么?

Java 更适合于“大规模开发”或企业应用程序开发,而不是 Perl、Awk 或 Python 中的单行一次性脚本。这是因为它是一种编译型的面向对象语言。然而,随着 JShell 的出现(参见 Recipe 1.4),这种适用性在某种程度上有所改变。我用较短的 Java 类示例甚至是代码片段来说明许多技术;其中一些较简单的技术将使用 JShell 展示。所有的代码示例(除了一些一两行的代码)都在我的一个公共 GitHub 存储库中,因此你可以放心,这里所见到的每一段代码都已经编译过,而且大多数最近都运行过。

本书中一些较长的示例是我最初编写的工具,用于自动化一些单调的任务。例如,一个名为MkIndex(在javasrc存储库中)的工具读取我保存 Java 示例源代码的顶级目录,并构建一个适合浏览器的index.html文件。另一个例子是XmlForm,它用于将手稿的部分从 XML 转换为另一种出版软件所需的形式。XmlForm还通过另一个程序GetMark处理来自javasrc目录的完整和部分代码插入到书稿中。我提到的 Github 存储库中包含XmlForm,以及GetMark的后续版本,尽管这两者都没有用于第四版的构建。如今,O’Reilly 的 Atlas 出版软件使用Asciidoctor,它提供了我们用于向书中插入文件和文件部分的机制。

本书的组织结构

让我们来看看本书的组织结构。每一章由几个食谱组成,描述了一个问题及其解决方案,以及一个代码示例。每个食谱中的代码都旨在基本上是自包含的;欢迎你在自己的项目中借用其中的一些片段。该代码以伯克利样式的版权分发,仅仅是为了阻止全面复制。

我从第一章,入门:编译和运行 Java开始,描述了在不同平台上编译程序的方法,以及在不同环境中运行它们(浏览器、命令行、窗口化桌面)和调试方法。

第二章,与环境交互,从编译和运行程序转向使其适应周围环境——计算机中存在的其他程序。

接下来的几章将涉及基本 API。第三章,字符串和相关内容集中讲解了 Java 中最基本但强大的数据类型之一,展示了如何组装、拆分、比较和重新排列你可能认为是普通文本的内容。本章还涵盖了国际化/本地化的话题,使得你的程序在阿克巴、阿富汗、阿尔及尔、阿姆斯特丹和法国的使用体验与在阿尔伯塔、阿肯色和阿拉巴马州的使用体验一样。

第四章,使用正则表达式进行模式匹配教你如何在许多字符串匹配和模式匹配问题领域中使用 Unix 的强大正则表达式技术。正则表达式处理在 Java 中已经标准化多年,但如果你不知道如何使用它,你可能会重新发明轮胎的平坦。

第五章,数字讨论了内置的数值类型,如intdouble,以及相应的 API 类(IntegerDouble等)和它们提供的转换和测试功能。还简要提到了“大数”类。由于 Java 程序员经常需要处理日期和时间,无论是本地的还是国际的,第六章,日期和时间涵盖了这个重要主题。

接 接下来的几章将涵盖数据处理。与大多数语言一样,Java 中的数组是线性的、索引的相似对象集合,如第七章,使用 Java 构建数据结构中讨论的那样。本章将继续介绍许多集合类:在java.util包中存储大量对象的强大方式,包括 Java 泛型的使用。

尽管在语法上与 C 等过程语言有些相似,Java 从根本上是一种面向对象编程(OOP)语言,并巧妙地融合了一些重要的函数式编程(FP)构造。第八章,面向对象的技术讨论了 OOP 的一些关键概念,包括常被重写的方法java.lang.Object以及设计模式的重要问题。Java 不是,也永远不会是,纯粹的 FP 语言。然而,使用一些 FP 的特性是可能的,随着 Java 8 及其对 lambda 表达式(即闭包)的支持,这一点变得越来越普遍。第九章,函数式编程技术:函数式接口、流和并行集合对此进行了讨论。

下一章涉及传统输入和输出的方面。第十章,“输入和输出:读取、写入和目录技巧”,详细说明了读取和写入文件的规则(如果您认为文件很无聊,请不要跳过这一章;您将在后面的章节中需要这些信息)。该章还向您展示了有关文件的其他内容——如查找其大小和上次修改时间——以及关于读取和修改目录、创建临时文件和重命名磁盘上的文件的所有其他信息。

大数据和数据科学已经成为一个事物,而 Java 正好适用于此。Apache Hadoop、Apache Spark 等大数据基础设施的大部分都是用 Java 编写的,并且可以通过 Java 进行扩展,如第十一章,“数据科学和 R”所述。R 编程语言在数据科学家、统计学家和其他科学家中很受欢迎。至少有两个用 Java 编写的 R 重新实现,并且 Java 也可以直接与标准 R 实现双向接口,因此本章也涵盖了 R。

因为 Java 最初被宣传为互联网的编程语言,花点时间在 Java 网络编程上是公平的。第十二章,“网络客户端”,介绍了从客户端角度讲解网络编程的基础知识,重点介绍了套接字。如今,许多应用程序需要访问 Web 服务,主要是 RESTful Web 服务,因此这似乎是必要的。然后我将在第十三章,“服务器端 Java”中转向服务器端,您将在其中学习一些服务器端编程技术。

用于数据交换的一种简单的基于文本的表示形式是 JSON,即 JavaScript 对象表示法。第十四章,“处理 JSON 数据”,描述了该格式以及一些出现的许多用于处理它的 API。

第十五章,“包和打包”,展示了如何创建一起工作的类包。本章还讨论了部署(又名分发和安装)软件的方法。

第十六章,“Java 线程”,告诉您如何编写看起来可以同时执行多个操作的类,并利用强大的多处理器硬件。

第十七章,“反射,或“一个名为 Class 的类””,让您了解一些秘密,例如如何机械地编写 API 交叉引用文档以及 Web 服务器如何加载任何旧的 Servlet——从未见过该特定类并运行它。

有时您已经编写并在另一种语言中运行的代码可以为您的部分工作,或者您希望将 Java 用作较大程序包的一部分。第十八章,“使用其他语言与 Java”,向您展示了如何运行外部程序(已编译或脚本),并直接与 C/C++或其他语言中的本机代码交互。

这本书的篇幅不足以包含我想告诉你的关于 Java 的一切。后记提供了一些结尾思考,并链接到我整理的每个 Java 开发人员都应了解的 Java API 的在线摘要。

最后,附录 A,Java Then and Now,以版本发布时间线的形式呈现了 Java 的传奇历史,因此无论你学习的是哪个版本的 Java,你都可以快速了解最新情况。

如此之多的主题,但页面有限!许多主题没有得到百分之百的覆盖;我试图包含每个 API 最重要或最有用的部分。要深入了解,请查看每个包的官方javadoc页面;许多这些页面上都有一些关于包如何使用的简短教程信息。

除了本书涵盖的 Java 部分外,还有两个其他平台版本已经标准化。Java Micro Edition (Java ME) 专注于诸如手持设备、手机和传真机等小型设备。在规模较大的服务器设备上,有Eclipse Jakarta EE,它取代了以前的 Java EE,在上个世纪被称为 J2EE。Jakarta EE 专注于构建大型、可扩展的分布式应用程序。Jakarta EE 的 API 包括 Servlets、JavaServer Pages、JavaServer Faces、JavaMail、Enterprise JavaBeans (EJBs)、Container and Dependency Injection (CDI) 和 Transactions。Jakarta EE 的包通常以“javax”开头,因为它们不是核心包。本书仅涉及其中几个;还有一个Java EE 8 Cookbook由 Elder Moraes (O’Reilly) 编写,涵盖了一些 Jakarta EE 的 API,以及一个更早的Java Servlet & JSP Cookbook由 Bruce Perry (O’Reilly) 编写。

本书完全不涵盖 Java Micro Edition、Java ME。但说到手机和移动设备,你可能知道 Android 使用 Java 作为其编程语言。对 Java 开发人员而言,令人欣慰的是,Android 也使用了大部分核心 Java API,只是对于 Swing 和 AWT 提供了特定于 Android 的替代品。想要学习 Android 的 Java 开发人员可以考虑查看我的Android Cookbook (O’Reilly),或者这本书的网站

Java 图书

这本书包含了大量有用的信息。但由于涵盖的主题广泛,不可能对任何一个主题进行书籍长度的处理。因此,本书包含了许多网站和其他书籍的参考资料。在指出这些参考资料时,我希望能为我的目标读者提供帮助:那些想要更多了解 Java 的人。

在我看来,O'Reilly 出版的 Java 书籍市场上是最佳选择。随着 API 的不断扩展,它们的覆盖范围也在增加。查看完整的O'Reilly Java 书籍系列,你可以在大多数实体书店和虚拟书店购买它们。你也可以通过O'Reilly 在线学习平台,一个付费订阅服务,在线阅读它们。当然,大多数书籍现在也提供电子书格式;O'Reilly 的电子书是无 DRM 的,因此你不必担心它们的拷贝保护方案将你锁定在特定的设备或系统上,就像某些其他出版商的做法一样。

尽管本书在适当的位置提到了许多书籍,但在这里还值得特别一提几本。

首先,David Flanagan 和 Benjamin Evan 的Java 程序员快速参考(O'Reilly)提供了语言和 API 的简要概述,以及对最重要的包的详细参考。这本书很方便放在你的电脑旁边。Bert Bates 和 Kathy Sierra 的Head First Java则是一本更为风趣的入门书,推荐给经验较少的开发者。

Java 8 Lambdas(Warburton,O'Reilly)介绍了 Java 8 引入的 Lambda 语法,支持函数式编程和更简洁的代码。

Java 9 模块化:开发可维护应用程序的模式与实践(Sander Mak 和 Paul Bakker,O'Reilly)涵盖了 Java 9 语言中关键的更改,专注于 Java 模块系统。

Java 虚拟机(Jon Meyer 和 Troy Downing,O'Reilly)将会吸引那些想深入了解底层技术的人。这本书已经停印,但可以在二手市场和图书馆找到。

一本权威(而且庞大)的 Swing GUI 编程书籍是Java Swing(Robert Eckstein 等人,O'Reilly)。

Java 网络编程Java I/O,均由 Elliotte Harold(O'Reilly)编著,也是有用的参考资料。

对于 Java 数据库工作,推荐Java 数据库编程与 JDBC & Java(George Reese,O'Reilly)和Pro JPA 2: 掌握 Java 持久化 API(Mike Keith 和 Merrick Schincariol,Apress)。或者我的即将推出的Java 数据库概述

虽然你现在读的这本书没有涵盖 Java EE 的内容,但我想提到两本相关的书:

  • 阿伦·古普塔在《Java EE 7 Essentials》(O’Reilly)中详细介绍了企业版。

  • 亚当·比恩的《真实世界 Java EE 模式:重新思考最佳实践》(http://realworldpatterns.com)提供了在设计和实现企业应用程序中的有用见解。

您可以在O’Reilly 网站找到更多信息。

最后,尽管它不是一本书,《Java 信息》(https://docs.oracle.com/en/java/javase/13/docs)在网络上有大量内容。该网页曾展示了一张显示 Java 所有组件的大图,这是一个“概念图”。早期版本显示在图 P-1 中;每个彩色框都是指向该特定技术详细信息的可点击链接。

jcb4 0001

图 P-1. Oracle 文档中的 Java 概念图

不管好坏,Java 的新版本已将此替换为文本页面;对于 Java 13,该页面位于https://docs.oracle.com/en/java/javase/13

一般编程书籍

唐纳德·E·克努特的《计算机程序设计艺术》(Addison-Wesley)自 1968 年首次出版以来,一直是计算机学生们的灵感来源。第 1 卷涵盖了基本算法,第 2 卷是半数值算法,第 3 卷是排序和搜索,第 4A 卷是组合算法,第一部分。预计系列中的其余卷尚未完成。尽管他的示例与 Java 差距很大(他为示例发明了假想的汇编语言 MIX),但他对算法的讨论——关于计算机如何解决实际问题——与多年前一样,至今仍然相关。²

尽管它的代码示例现在看来有些过时,《编程风格的元素》一书由布赖恩·克尼甘和 P. J. 普劳格(麦格劳希尔)撰写,为一代程序员设定了风格基调。克尼甘和普劳格还合著了一对书籍,《软件工具》(Addison-Wesley)和《Pascal 语言中的软件工具》(Addison-Wesley),这两本书提供了大量关于编程的良好建议,以至于我曾经建议所有程序员都应该阅读。然而,这三本书现在已经过时;许多时候我想要用一种更现代的语言写一本后续书籍。现在我转而推荐克尼甘的后续之作《编程实践》—与罗布·派克(Addison-Wesley)合著—作为《软件工具》系列的延续。这本书延续了贝尔实验室在软件教科书中的卓越传统。在本书的早期版本中,我甚至从他们的书中借鉴了一小段代码,他们的 CSV 解析器。最后,克尼甘最近出版了《UNIX:历史与回忆》,他对 Unix 故事的诠释。

还请参阅安德鲁·亨特和大卫·托马斯的 实用编程(Addison-Wesley)。

设计书籍

彼得·科德的 Java 设计(PTR-PH/Yourdon Press)专门讨论了针对 Java 的面向对象分析和设计的问题。科德对 Java 的可观察者-观察者范式的实现持有一定的批评态度,并提供了自己的替代方案。

近年来关于面向对象设计最著名的书之一是 设计模式,作者是埃里希·伽玛、理查德·赫尔姆、拉尔夫·约翰逊和约翰·弗利西德斯(Addison-Wesley)。这些作者通常被称为“四人帮”,导致他们的书有时被称为 GoF 书。我的一位同事称它为“有史以来最好的面向对象设计书籍”,我同意;至少,它是最好之一。

马丁·福勒的 重构(Addison-Wesley)涵盖了很多可应用于代码的“清理”工作,以提高可读性和可维护性。正如 GoF 书籍引入了有助于开发人员和其他人沟通代码设计方式的新术语一样,福勒的书提供了一个讨论如何改进代码的词汇表。但这本书可能不如其他书有用;现在许多重构内容已经出现在 Eclipse IDE 的重构菜单中(参见 Recipe 1.3)。

目前流传的两个重要方法论流派。第一个被称为敏捷方法,最著名的成员是 Scrum 和极限编程(XP)。XP(方法论,而不是那个真的很老的微软 OS 版本)由其设计者肯特·贝克领导,以一系列小型、简短、易读的文本呈现。XP 系列的第一本书是 极限编程解析(Addison-Wesley)。对所有敏捷方法的很好概述是吉姆·海斯密斯的 敏捷软件开发生态系统(Addison-Wesley)。

另一组重要的方法论书籍,涵盖了更传统的面向对象设计,是由“三位好友”(布奇、雅各布森和朗保)领导的 UML 系列。他们的主要作品是 UML 用户指南UML 过程 等等。同一系列中一本更小、更易接近的书是马丁·福勒的 UML 精要

本书中使用的约定

本书使用以下约定。

编程约定

本书中我使用以下术语。程序指任何可运行的代码单元:从五行主程序到 Servlet 或 Web 层组件、EJB 或完整的 GUI 应用程序。小程序是用于在 Web 浏览器中使用的 Java 程序;这些曾一度流行,但今天几乎不存在。Servlet 是使用 Jakarta EE API 构建的 Java 组件,用于通过 HTTP 在 Web 服务器中使用。EJB 是使用 Jakarta API 构建的业务层组件。应用程序指任何其他类型的程序。桌面应用程序(也称为客户端)与用户进行交互。服务器程序间接与客户端交互,通常通过网络连接(今天通常是 HTTP/HTTPS)。

所示示例有两种变体。以零个或多个导入语句、一个 javadoc 注释和一个public class语句开头的是完整示例。以声明或可执行语句开头的当然是摘录。但这些摘录的完整版本已经被编译和运行,在线资源包括完整版本。

配方按章节和编号编号,例如,Recipe 8.1 指的是第八章中的第一个配方。

排版约定

本书使用以下排版约定:

斜体

用于命令、文件名和示例网址。在文本中首次出现时,还用于强调和定义新术语。

等宽字体

在代码示例中用于显示部分或完整的 Java 源代码程序清单。还用于类名、方法名、变量名和其他 Java 代码片段。

等宽粗体字体

用于用户输入,例如在命令行上键入的命令。

等宽斜体字体

显示应该被用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此图标表示警告或注意事项。

代码示例

本书的代码示例位于作者的 GitHub 上。大多数位于仓库javasrc,但少数从另一个仓库darwinsys-api中拉取。有关下载这些内容的详细信息,请参见 Recipe 1.6。

许多程序都附有示例,展示它们在命令行中的运行方式。这些示例通常会显示以$为结尾的 Unix 提示符或以>为结尾的 Windows 提示符,具体取决于我编写示例时使用的计算机。如果在这个提示符字符之前有文本,可以忽略它。它可能是路径名或主机名,同样取决于系统。

当从命令行启动程序时,这些示例通常还会显示类的完整包名称。因为 Java 要求这样做,而且这将提醒您在源代码库的哪个子目录中找到源代码,所以我不会经常明确指出它。

我们感谢但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“Java Cookbook by Ian F. Darwin (O’Reilly). Copyright 2020 RejmiNet Group, Inc., 978-1-492-07258-4。”

如果您认为您对代码示例的使用超出了合理使用范围或以上授权,请随时通过permissions@oreilly.com与我们联系。

奥莱利在线学习

注意

40 多年来,奥莱利媒体已为技术和商业培训提供了知识和见解,以帮助公司取得成功。

我们独特的专家和创新者网络通过图书、文章、会议和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及奥莱利和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问http://oreilly.com

评论和问题

正如前面提到的,我已经在至少一个参考平台上测试了所有代码,大多数情况下是在多个平台上测试过。尽管如此,我的代码或某些重要的 Java 实现中可能存在平台依赖性或甚至错误。请报告您发现的任何错误以及对未来版本的建议,写信至:

  • 奥莱利媒体公司

  • 1005 Gravenstein Highway North

  • CA 95472,Sebastopol

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

本书有一个网页,我们列出了勘误、示例和任何额外信息。您可以访问http://shop.oreilly.com/product/0636920304371.do

电子邮件bookquestions@oreilly.com以评论或询问有关本书的技术问题。

欲了解更多关于我们的图书、课程、会议和新闻的信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

奥莱利网站列出了勘误表。您也可以找到所有 Java 代码示例的源代码以供下载;不要浪费时间重新输入它们!有关具体说明,请参见 Recipe 1.6。

致谢

我在第一版的后记中写道,“写这本书是一次令人印象深刻的经历。”我应该补充说,维护这本书也同样令人感到印象深刻。尽管许多人对此赞不绝口——一位非常友好的评论者称其为“有史以来关于 Java 编程语言写得最好的书”——但我被早期版本中的错误和遗漏数量所震惊。我努力进行了改正。

生活中多次被命运的流动触及,让我与正确的人在正确的时间接触到正确的事物。与我早已失去联系的史蒂夫·芒罗在我们高中同班时向我介绍了计算机,特别是多伦多教育委员会的一台比起客厅还大、有 32 或 64K(不是 M 或 G!)内存的 IBM 360/30。后来在多伦多大学,已故的赫伯特·库格尔在我学习后来的更大型 IBM 主机时关心着我。特里·伍德和丹尼斯·史密斯在我接触 IBM PC 之前介绍了我迷你和微型计算机。在多伦多商业俱乐部的 Toastmasters International 和阿尔·兰伯特的加拿大潜水学校,我得以发展我的演讲和教学能力。多伦多大学的几位人士,尤其是杰弗里·科利尔,教会了我 Unix 操作系统的特性和好处,正是在我准备学习它的时候。

感谢许多Learning Tree的讲师和学生向我展示了如何改进我的演讲。我仍然为“The Tree”教书,并推荐他们的课程给那些希望在四天内深入了解一个主题的忙碌开发者。

离这个项目更近的是,当我的“小林书”只是一个提议的更长作品的样章时,蒂姆·奥莱利相信了它,使我得以早日进入奥莱利作者的尊贵圈子。多年后,迈克·劳基德斯鼓励我继续寻找一个我们两个都能合作的 Java 书籍创意。在我一次次未能按时完成截稿时,他一直支持着我。迈克还阅读了整个手稿,并提出了许多明智的意见,其中一些把不切实际的想法拉回了现实。杰萨敏·里德将许多传真和电子邮件上的难以辨认的涂鸦变成了你在这本书中看到的高质量插图。还有许多奥莱利的其他才华横溢的人帮助把这本书变成了你现在看到的形态。

现在代码示例已经动态包含(因此更新更快),而不是直接粘贴进来。我的儿子(功能编程开发者)Benjamin Darwin 通过将几乎整个代码库转换为 O'Reilly 最新的“包含”机制,并解决其他几个非 Java 展示问题,帮助满足了截止日期。他还帮助使第九章更清晰和更实用。

在 O'Reilly

在这本书的第四版中,Suzanne McQuade 担任编辑监督,Corbin Collins 担任主编。Corbin 在校对手稿方面尤为细致。Meghan Blanchette、Sarah Schneider、Adam Witwer、Melanie Yarbrough 以及在版权页列出的许多制作人员都为第三版的完成贡献了自己的力量。感谢 Mike Loukides、Deb Cameron 和 Marlowe Shaeffer 在第二版的编辑和制作工作中的贡献。

技术审阅员

对于第四版,我很荣幸能够拥有两位非常彻底的技术审阅员,Sander Mak 和 Daniel Hinojosa。这两位指出了我在主要修订期间未考虑到的许多问题,在 O'Reilly 制作团队接管前的最后几周进行了广泛的重写和修改。非常感谢你们两位!

我的第三版审稿人 Alex Stangl 阅读了第三版的手稿,并超出了职责范围,提出了无数有益的建议,甚至发现了之前版本中存在的错别字!Benjamin Darwin、Mark Finkov 和 Igor Savin 对特定章节提出了有益的建议。如果我遗漏了任何人,请接受我诚挚的感谢!

Bil Lewis 和 Mike Slinn 在第一版的多个草稿中提出了有益的评论。Ron Hitchens 和 Marc Loy 仔细阅读了第一版的整个最终草稿。我非常感谢 Mike Loukides 在整个过程中的鼓励和支持。编辑 Sue Miller 在生产的最后阶段积极推动手稿的完成。Sarah Slocombe 阅读了 XML 章节的全文,并提出了许多清晰的建议;不幸的是,时间不允许我在第一版中包含所有建议。

Jonathan Knudsen、Andy Oram 和 David Flanagan 在这本书的大纲还只是章节列表时提出了评论,他们能够看到这本书可能成为的类型,并提出了改进的方法。

每一位参与者都在很多方面使这本书变得更好,特别是通过提出额外的配方建议或修改现有的配方。感谢每一位!尚存的错误均属于我的疏忽。

读者

我衷心感谢所有找到勘误并提出改进建议的读者。每一版新书都因像你们这样花时间和精力来报告需要改进的地方的人而变得更好!

特别要提到本书的德文译者之一³,吉斯伯特·塞尔克(Gisbert Selke),他在翻译第一版时从头到尾阅读了整本书,并澄清了我的英语。吉斯伯特为第二版做了同样的工作,并提供了许多代码重构,使得这本书比其他情况下更好。吉斯伯特甚至超越了职责范围,为这本书做出了一份食谱(Recipe 18.5),并修改了同一章节中其他一些食谱。谢谢你,吉斯伯特!

第二版还受益于吉姆·伯吉斯的评论,他阅读了书的大部分内容。乔纳森·弗尔斯、已故的金·福勒、马克·洛伊和迈克尔·麦克洛斯基分别对各个章节提出了评论。我的妻子贝蒂和当时还是十几岁的孩子们也分别校对了几章内容。

以下人员提供了重要的错误报告或建议改进:雷克斯·博斯马、罗德·布坎南、约翰·张伯伦、基思·戈德曼、吉尔-菲利普·格雷戈瓦、B·S·休斯、杰夫·约翰斯顿、罗布·康尼斯伯格、汤姆·默塔格、乔纳森·奥康纳、马克·彼得罗维奇、史蒂夫·赖斯曼、布鲁斯·X·史密斯和帕特里克·沃尔温德。

等等。

亲爱的妻子贝蒂·赛拉依然对我在编程时喝的咖啡充满了了解,比我使用的编程语言更加了解,但是她对清晰表达和正确语法的热情在我们共同生活的岁月里给我的写作带来了很多好处。

没有一本关于 Java 的书能完整地没有感谢詹姆斯·高斯林发明 Java(他还发明了第一个 Unix Emacs、sc电子表格和 NeWS 窗口系统)。同时也感谢他的雇主 Sun Microsystems(在被 Oracle 收购之前),他们不仅发布了 Java 语言,还在互联网上免费提供了一系列令人难以置信的 Java 工具和 API 库。

在第一版的早期,苹果加拿大的威利·鲍威尔为我提供了 macOS 的访问权限。

对于每一位贡献者,我真诚地感谢你们。

书籍制作软件

在准备、编译和测试本书时,我使用了各种工具和操作系统。值得感谢 OpenBSD 的开发者们,他们创造了“主动安全的类 Unix 系统”,提供了一个稳定和安全的 Unix 克隆系统,比其他免费软件更接近传统 Unix。我在输入原始手稿的 XML 时使用了vi编辑器(在 OpenBSD 上是vi,在 Windows 上是vim),并使用 Adobe FrameMaker(一个精美的基于 GUI 的文档工具,后来被 Adobe 收购并毁掉)来格式化文档。我不知道我是否能原谅 Adobe 毁掉了可以说是世界上最好的文档系统,还通过保持充满 bug 的 Flash 活跃使互联网变得如此危险。但我知道我永远不会再使用 Adobe 的文档系统来做任何事情。

由于此原因,我编辑的众包 Android Cookbook 并不是用 FrameMaker 准备的,而是使用了 XML DocBook(从我为此目的编写的基于 Java 的维基标记生成)和 O'Reilly 工具组提供的一些定制工具。

Java Cookbook 的第三版和第四版是用 Asciidoctor 格式化的,并在 O'Reilly 的 Atlas 出版工具链上实现。

¹ 有关怪癖,请参见 Joshua Bloch 和 Neal Gafter 的 Java Puzzlers books(Addison-Wesley)。

² 在考虑到目前计算能力的巨大变化后,对于不再那么相关的算法决策可能会有例外情况。

³ 较早的版本有或者曾经有英文、德文、法文、波兰文、俄文、韩文、繁体中文和简体中文版本。感谢所有翻译人员为使这本书能够面向更广泛的读者群体所做的努力。

第一章:入门:编译和运行 Java

1.0 引言

本章涵盖了一些入门级的任务,你需要知道如何在开始之前做这些事情。据说你必须先爬行,然后才能行走,再之后才能骑自行车。在本书中尝试任何东西之前,你需要能够编译和运行你的 Java 代码,因此我从这里开始,展示了几种方法来实现:JDK 方法、集成开发环境(IDE)方法以及构建工具(Ant、Maven 等)方法。另一个人们遇到的问题是正确设置CLASSPATH,所以接下来处理这个问题。之后是关于弃用警告的信息,因为你在维护旧 Java 代码时可能会遇到它们。本章以关于条件编译、单元测试、断言和调试的一般信息结束。

如果你还没有安装 Java,你需要下载它。请注意,有几种不同的下载选项。直到 Java 8,JRE(Java 运行环境)是面向最终用户的一个较小的下载包。由于现在桌面 Java 的使用远不如从前,JRE 已被淘汰,取而代之的是jlink,用于创建自定义下载(参见 Recipe 15.8)。JDK 或 Java SDK 下载是完整的开发环境,如果你打算开发 Java 软件,这是你需要的。

当前 Java 版本的标准下载可在Oracle 网站找到。

有时你可以在http://jdk.java.net找到下一个主要 Java 版本的预发布版本。整个 JDK 作为一个开源项目进行维护,OpenJDK 源代码树用于构建商业和支持的 Oracle JDK(经过修改和增加)。

如果你已经对你的集成开发环境(IDE)满意,你可能希望跳过这部分或全部内容。这里的内容确保每个人在继续之前都能够编译和调试他们的程序。

1.1 编译和运行 Java:标准 JDK

问题

你需要编译和运行你的 Java 程序。

解决方案

这是你的计算机操作系统影响 Java 可移植性的少数几个领域之一,因此让我们先解决这些问题。

JDK

使用命令行 Java 开发工具包(JDK)可能是跟进 Java 最新改进的最佳方式。假设你已经在标准位置安装了标准 JDK 并且将其位置设置在你的PATH中,你应该可以运行命令行 JDK 工具。使用命令 javac 进行编译和 java 运行你的程序(在 Windows 上还有 javaw 用于无控制台窗口运行程序),如下所示:

C:\javasrc>javac HelloWorld.java

C:\javasrc>java HelloWorld
Hello, World

C:\javasrc>

如果程序引用其他类,这些类的源代码在同一目录中可用且没有编译过的 .class 文件,javac 将自动为您编译它们。从 Java 11 开始,对于不需要任何这种共同编译的简单程序,您可以通过简单地将 Java 源文件传递给 java 命令来合并这两个操作:

	$ java HelloWorld.java
	Hello, Java
	$

正如你从编译器的(缺少)输出中可以看到的,javacjava 编译工作采用了 Unix 的“没有消息就是好消息”的哲学:如果程序能够按照你的要求执行,它就不应该烦扰你告诉它已经完成了。

还有一个可选设置叫做 CLASSPATH,在 Recipe 1.5 中讨论,它控制 Java 查找类的位置。如果设置了 CLASSPATHjavacjava 都会使用它。在旧版本的 Java 中,即使是从当前目录运行一个简单程序,你也必须将 CLASSPATH 设置为包含“.”;但在当前的 Java 实现中,这已经不再需要。

Sun/Oracle 的 javac 编译器是官方的参考实现。还有几个替代的开源命令行编译器,包括JikesKaffe,但它们大多数情况下已经不再积极维护。

也有一些 Java 运行时的克隆品,包括Apache HarmonyJaphar,IBM Jikes Runtime(与 Jikes 相同的站点),甚至 JNode,一个完整的、独立的用 Java 编写的操作系统;但自从 Sun/Oracle 的 JVM 开源(GPL)以来,大多数这些项目已经停止维护。Apache 于 2011 年 11 月退役了 Harmony。

macOS

JDK 纯命令行。在键盘与可视化之间的另一端,我们有苹果 Macintosh。有关 Mac 用户界面有很多好评,我不想卷入那场辩论。macOS(OS 版本 10.x)建立在 BSD Unix(和“Mach”)基础上。因此,它既有常规的命令行(Terminal 应用程序,隐藏在 /Applications/Utilities 下),也有传统的 Unix 命令行工具和图形化的 Mac 工具。如果你使用 macOS,你可以使用命令行 JDK 工具或任何现代构建工具。编译后的类可以使用在 Recipe 15.6 中讨论的 Jar 打包工具打包为可点击的应用程序。Mac 粉丝可以使用在 Recipe 1.3 中讨论的许多完整 IDE 工具之一。苹果提供 XCode 作为其 IDE,但原装状态下它对 Java 并不友好。

1.2 编译和运行 Java:GraalVM 提升性能

问题

你听说过 Graal 是 Oracle 推出的比标准 JDK 更快的 JVM,你想试一试。Graal 承诺提供更好的性能,并且支持混合编程语言以及将你的 Java 代码预编译为特定平台的可执行形式。

解决方案

下载并安装 GraalVM。

讨论

GraalVM 自称为“一个通用的虚拟机,用于运行 JavaScript、Python、Ruby、R,基于 JVM 的语言如 Java、Scala、Clojure、Kotlin,以及基于 LLVM 的语言,如 C 和 C++。”

注意,Graal 正在快速变化。虽然此处的步骤反映了出版时(2019 年末)的最新信息,但到你准备安装时,可能已经有了更新版本和功能上的变化。

我们编写时,GraalVM 基于 OpenJDK 11,这意味着你可以使用模块和其他 Java 9、10 和 11 特性,但不支持 Java 12、13 或 14 的功能。你可以在后续版本上构建自己的 Graal,因为 完整的源代码位于 GitHub 上

查看 GraalVM 网站 获取更多关于 GraalVM 的信息。还可以参考由 Chris Thalinger(他在 JVM 领域工作了十五年)所做的 这个演示

从下载页面开始。你需要在社区版和企业版之间做出选择。为避免任何许可问题,此处的步骤从社区版开始。你可以在 Linux、macOS 和 Windows 上下载 tarball。目前还没有正式的安装程序。要安装它,请打开终端窗口并尝试以下操作(选择的目录适用于 macOS):

$ cd /Library/Java/JavaVirtualMachines
$ tar xzvf ~/Downloads/graalvm-ce-NNN-VVV.tar.gz # replace with actual version
$ cd
$ /usr/libexec/java_home -V # macOS only
11.0.2, x86_64:    "OpenJDK 11.0.2"
    /Library/Java/JavaVirtualMachines/jdk-11.0.2.jdk/Contents/Home
1.8.0_221, x86_64:    "GraalVM CE 19.2.0.1"
    /Library/Java/JavaVirtualMachines/graalvm-ce-19.2.0.1/Contents/Home
$

在其他系统上,安装应放在合适的位置。在大多数 Linux 版本上,安装 JDK 后,可以使用标准的 Linux alternatives 命令 来设置为默认。在 MacOS 上,java_home 命令的输出确认你已安装了 GraalVM,但它还不是你的默认 JVM。要做到这一点,你需要设置你的 PATH

export JAVA_HOME=<where you installed GraalVM>/Contents/Home
export PATH=$JAVA_HOME/bin:$PATH

确保在行末包含 :$PATH ——没有空格——否则你所有的标准命令行工具都将消失(如果你犯了这个错误,只需退出并重新登录即可恢复你的路径)。我建议你在确定你的设置是正确的之前不要更新登录脚本。

现在你应该正在运行 Graal 版本的 Java。你应该看到以下内容:

$ java -version
openjdk version "1.8.0_222"
OpenJDK Runtime Environment (build
  1.8.0_222-20190711112007.graal.jdk8u-src-tar-gz-b08)
OpenJDK 64-Bit GraalVM CE 19.2.0.1 (build 25.222-b08-jvmci-19.2-b02, mixed mode)

输出可能不同,但只要显示“GraalVM”,你就没错。

Graal 包含许多有用的工具,包括 native-image,在某些情况下可以将类文件转换为运行平台的二进制可执行文件,优化启动速度并减少运行单个应用程序所需的下载大小。native-image 工具必须单独下载,使用 gu install native-image

我们将在 第 18.4 节 中探索运行一些非 Java 语言。

1.3 使用 IDE 进行编译、运行和测试

问题

对于各种开发任务使用多个工具非常繁琐。

解决方案

使用集成开发环境(IDE),它结合了编辑、测试、编译、运行、调试和包管理功能。

讨论

许多程序员发现使用一些单独的工具——文本编辑器、编译器和运行程序,更不用说调试器——是太多了。一个 IDE 整合 所有这些功能到一个带有图形用户界面的单一工具集中。许多 IDE 都可用,并且较好的 IDE 是具有自己编译器和虚拟机的完全集成工具。类浏览器和其他 IDE 功能完善了这些工具的易用性特性。今天,大多数开发者使用 IDE 因为它们提升了生产力。尽管我最初是一个命令行爱好者,但我发现 IDE 的以下功能使我更加高效:

代码完成:: Ian’s Rule 是我从不输入已知于 IDE 的任何名称的超过三个字符;让计算机完成打字工作!增量编译功能:: 在键入时即时通知和报告编译错误,而不是等到完成输入后再检查。重构:: 在不手动编辑数十个单独文件的情况下,进行深远但保留行为的更改的能力。

除此之外,我不打算讨论 IDE 与命令行处理的优缺点;在不同的时间和项目中,我都会使用两种模式。我只是打算展示使用几个基于 Java 的 IDE 的几个示例。

三个最流行的 Java 集成开发环境(IDE),可在所有主流计算平台和一些小众平台上运行,分别是 EclipseNetBeansIntelliJ IDEA。Eclipse 是最广泛使用的,但其他两个在一些开发者心目中各有特殊地位。如果你开发 Android 应用,ADT 传统上是为 Eclipse 开发的,但现在已过渡到 IntelliJ 作为 Android Studio 的基础,后者是 Android 的标准 IDE,也是 Google 的另一个移动平台 Flutter 的基础。这三个 IDE 都是基于插件的,提供了大量可选的第三方插件,用于增强 IDE 的功能,例如支持其他编程语言、框架和文件类型。虽然下面的段落展示了如何使用 Eclipse 创建和运行程序,但 IntelliJ IDEA 和 NetBeans IDE 都提供了类似的功能。

Eclipse 是最流行的跨平台开源 Java IDE 之一,最初由 IBM 开发,现由Eclipse Foundation管理,这是许多软件项目的家园,包括Jakarta,即 Java 企业版的后继者。Eclipse 平台还作为其他工具的基础,如 SpringSource Tool Suite (STS)和 IBM 的 Rational Application Developer (RAD)。所有的 IDE 在起步时基本都为你提供相同的功能。例如,图 1-1 展示了如何开始一个新项目。

jcb4 0101

图 1-1. 使用 Eclipse 新建 Java 类向导开始一个新项目

Eclipse 新建 Java 类向导如图 1-2 所示,展示了创建新类的过程。

jcb4 0102

图 1-2. 使用 Eclipse 新建 Java 类向导创建新类

Eclipse 和现代所有 IDE 一样,具备多项重构能力,如图 1-3 所示。

jcb4 0103

图 1-3. Eclipse 中的重构操作

当然,所有的 IDE 都允许你运行和/或调试你的应用程序。图 1-4 展示了运行一个应用程序的过程;为了多样性和中立性,此处展示使用的是 IntelliJ IDEA。

macOS 包含了 Apple 的开发工具,主要 IDE 是 Xcode。不幸的是,当前版本的 Xcode 实际上并不支持 Java 开发,所以我无法推荐它用于我们的目的;它主要用于构建非可移植(仅限 iOS 或仅限 OS X)的应用程序,使用 Swift 或 Objective-C 编程语言。因此,即使你使用 OS X,如果要进行 Java 开发,你应该使用三大 Java IDE 之一。

最近,Microsoft VSCode(原属于 Visual Studio 的一部分)在 Java 领域引起了一些关注,但它并不是一个专门针对 Java 的 IDE。如果你喜欢,可以试试看。

如何选择一个 IDE 呢?也许会由你的组织决定,或者由你的开发团队中的多数人投票决定。考虑到 Eclipse、NetBeans 和 IntelliJ 这三个主要的 IDE 都可以免费下载并且是 100%开源的,为什么不都试试看,看哪一个最适合你的开发需求呢?不管你用什么平台开发 Java,只要有 Java 运行时,你都可以有很多 IDE 可供选择。

jcb4 0104

图 1-4. IntelliJ 程序输出

参见

每个 IDE 的网站都会维护一个更新的资源列表,包括书籍。查看表 1-1 获取每个 IDE 的网站信息。

表 1-1. 三大 Java IDE 及其网站

产品名称 项目 URL 备注
Eclipse https://eclipse.org/ STS、RAD 的基础
IntelliJ Idea https://jetbrains.com/idea/ Android Studio 的基础
Netbeans https://netbeans.apache.org 可在 JavaSE 支持的任何地方运行

这些主要的集成开发环境是可扩展的;查阅它们的文档以获取可用的许多插件列表。其中大多数允许您在 IDE 内查找和安装插件。对于 Eclipse,请使用 Eclipse Marketplace,在帮助菜单的底部附近。作为最后的手段,如果您需要/想要编写一个扩展 IDE 功能的插件,您也可以使用 Java 进行操作。

对于 Eclipse,我在https://darwinsys.com/java上有一些有用的信息。该网站包含了一些快捷方式列表,以帮助开发者提高生产效率。

1.4 使用 JShell 探索 Java

问题

您希望快速尝试 Java 表达式和 API,而不必每次创建一个包含public class X { public static void main(String[] args) { … }的文件。

解决方案

使用 JShell,Java 的 REPL(读取-求值-打印-循环)解释器。

讨论

从 Java 11 开始,JShell被包括为 Java 的标准部分。它允许您输入 Java 语句并对其进行评估,而无需创建类和主程序。您可以用它进行快速计算,尝试 API 以查看其工作原理,或几乎任何其他用途;如果找到您喜欢的表达式,您可以将其复制到常规 Java 源文件中,并使其永久化。JShell 还可以用作 Java 的脚本语言,但启动 JVM 的开销意味着它不如 awk、Perl 或 Python 那样快速进行脚本编写。

REPL 程序非常方便,而且它们并不是一个新的想法(上世纪 50 年代的 LISP 语言已经包括了它们)。您可以将命令行解释器(CLI)(例如 UNIX/Linux 上的 Bash 或 Ksh shell,或 Microsoft Windows 上的 Command.com 和 PowerShell)视为系统整体的 REPL。许多解释性语言如 Ruby 和 Python 也可以用作 REPL。Java 最终有了自己的 REPL,JShell。这里有一个使用它的示例:

$ jshell
|  Welcome to JShell -- Version 11.0.2
|  For an introduction type: /help intro

jshell> "Hello"
$1 ==> "Hello"

jshell> System.out.println("Hello");
Hello

jshell> System.out.println($1)
Hello

jshell> "Hello" + sqrt(57)
|  Error:
|  cannot find symbol
|    symbol:   method sqrt(int)
|  "Hello" + sqrt(57)
|            ^--^

jshell> "Hello" + Math.sqrt(57)
$2 ==> "Hello7.54983443527075"

jshell> String.format("Hello %6.3f", Math.sqrt(57)
   ...> )
$3 ==> "Hello  7.550"

jshell> String x = Math.sqrt(22/7) + " " + Math.PI +
   ...> " and the end."
x ==> "1.7320508075688772 3.141592653589793 and the end."

jshell>

您可以在这里看到一些明显的特性和优点:

  • 表达式的值会被打印出来,无需每次调用System.out.println,但如果您愿意,也可以调用它。

  • 未分配给变量的值会被分配合成标识符,如$1,可以在后续语句中使用。

  • 语句末尾的分号是可选的(除非您在一行上键入多个语句)。

  • 如果出现错误,您将立即收到一条有帮助的消息。

  • 您可以像在 shell 文件名补全中一样使用单个制表符完成。

  • 你只需双击标签,就可以获得有关已知类或方法的 Javadoc 文档的相关部分。

  • 如果省略了闭引号、括号或其他标点符号,JShell 将等待您,显示一个继续提示 ()。

  • 如果确实出现错误,您可以使用“shell 历史”(即向上箭头)来调出语句,以便修复它。

JShell 在原型化 Java 代码时也很有用。例如,我想要一个健康主题的计时器,提醒你每半小时起来活动一下:

$ jshell
|  Welcome to JShell -- Version 11.0.2
|  For an introduction type: /help intro

jshell> while (true) { sleep (30*60); JOptionPane.showMessageDialog(null,
  "Move it"); }
|  Error:
|  cannot find symbol
|    symbol:   method sleep(int)
|  while (true) { sleep (30*60); JOptionPane.showMessageDialog(null, "Move it");}
|                 ^---^
|  Error:
|  cannot find symbol
|    symbol:   variable JOptionPane
|  while (true) { sleep (30*60); JOptionPane.showMessageDialog(null, "Move it");}
|                                ^---------^

jshell> import javax.swing.*;

jshell> while (true) { Thread.sleep (30*60); JOptionPane.showMessageDialog(null,
"Move it"); }

jshell> while (true) { Thread.sleep (30*60 * 1000);
  JOptionPane.showMessageDialog(null, "Move it"); }

jshell> ^D

然后我将最终的工作版本放入了一个名为MoveTimer.java的 Java 文件中,围绕主要代码行加了一个class语句和一个main()方法,告诉 IDE 重新格式化整个内容,并将其保存到了我的darwinsys-api存储库中。

那就开始尝试使用 JShell 吧。阅读内置的入门教程以获取更多细节!当你找到喜欢的东西时,要么使用/save,要么将其复制粘贴到 Java 程序中并保存。

OpenJDK JShell 教程中了解更多关于 JShell 的内容。

1.5 有效使用 CLASSPATH

问题

你需要将类文件放在一个共同的目录中,否则你将要与CLASSPATH抗争。

解决方案

CLASSPATH设置为包含你想要的类的目录和/或 JAR 文件的列表。

讨论

CLASSPATH是一个包含在任意数量的目录、JAR 文件或 ZIP 文件中的类文件列表。就像你的系统用于查找程序的PATH一样,Java 运行时使用CLASSPATH来查找类。即使当你键入像java HelloWorld这样简单的命令时,Java 解释器也会在你的CLASSPATH中的每个命名位置查找,直到找到匹配项。让我们通过一个例子来进行说明。

CLASSPATH可以像设置其他环境变量(比如你的PATH环境变量)一样设置为一个环境变量。然而,通常最好为给定的命令在命令行上指定CLASSPATH

C:\> java -classpath c:\ian\classes MyProg

假设你的CLASSPATH设置为 Windows 上的C:\classes;.,或 Unix 或 Mac 上的~/classes:.。假设你刚刚在默认目录(也就是当前目录)中编译了一个名为HelloWorld.java(没有包声明)的源文件,生成了HelloWorld.class并尝试运行它。在 Unix 上,如果你运行了一个内核跟踪工具(tracestracetrussktrace),你可能会看到 Java 程序openstataccess以下文件:

  • JDK 目录中的一些文件

  • 然后是~/classes/HelloWorld.class,它可能找不到

  • 最后是./HelloWorld.class,它会找到、打开并读入内存

含糊的“JDK 目录中的一些文件”是与发行版相关的。你不应该去碰 JDK 文件,但如果你好奇的话,你可以在系统属性中找到它们(参见食谱 2.2)。以前有一个名为sun.boot.class.path的变量,但现在找不到了。让我们寻找任何名称中包含boot的属性:

jshell> System.getProperties().forEach((k,v) -> {
 ... if (((String)k).contains("boot")) System.out.println(k + "->" +v);})
sun.boot.library.path->/usr/local/jdk-11/lib

我和其他人建议不要将 CLASSPATH 设置为环境变量的原因是,我们不喜欢意外。很容易将一个 JAR 添加到你的 CLASSPATH,然后忘记你已经这样做了;程序可能会在你这里工作,但由于他们不知道你的隐藏依赖,对你的同事来说却无法工作。如果你在 CLASSPATH 中添加新版本而不删除旧版本,可能会遇到冲突问题。

还要注意,提供 -classpath 参数会导致 CLASSPATH 环境变量被忽略。

如果你仍然希望将 CLASSPATH 设置为环境变量,那是可以的。假设你还安装了包含本书程序支持类的 JAR 文件 darwinsys-api.jar(如果你下载的实际文件名包含版本号)。你可以将 CLASSPATH 设置为在 Windows 上是 C:\classes;C:\classes\darwinsys-api.jar;.,在 Unix 上是 /classes:/classes/darwinsys-api.jar:.

注意,你确实需要显式列出 JAR 文件的完整名称。与单个类文件不同,将 JAR 文件放入列在 CLASSPATH 中的目录中并不会使其可用。

某些专业程序(如运行 Servlet 容器的 Web 服务器)可能不会完全像显示的那样使用 bootpathCLASSPATH;这些应用服务器通常提供自己的 ClassLoader(参见 Recipe 17.5 获取有关类加载器的信息)。例如,EE Web 容器会将您的 Web 应用的 CLASSPATH 设置为包括目录 WEB-INF/classesWEB-INF/lib 下找到的所有 JAR 文件。

如何将类文件轻松生成到你的 CLASSPATH 目录中?javac 命令有一个 -d dir 选项,用于指定编译器输出的位置。例如,使用 -dHelloWorld 类文件放入我的 $HOME/classes 目录中,只需输入以下内容(请注意,从这里开始,我将使用包名加类名,像一个好孩子):

javac -d $HOME/classes HelloWorld.java
java -cp $HOME/classes starting.HelloWorld
Hello, world!

只要这个目录保持在我的 CLASSPATH 中,无论当前目录如何,我都可以访问类文件。这是使用 CLASSPATH 的主要好处之一。

虽然这些示例显示了使用 -classpathjava 的显式用法,但通常更方便(且可重现)使用构建工具如 Maven(参见 Recipe 1.7)或 Gradle,它们会自动为编译和执行提供 CLASSPATH

请注意,Java 9 及更高版本还有一个模块路径(环境变量 MODULEPATH,命令行参数 --module-path entry[:,…]),其语法与类路径相同。模块路径包含已模块化的代码;Java 模块系统在 Recipe 2.5 和 Recipe 15.9 中有所讨论。

1.6 下载和使用代码示例

问题

你想试试我的示例代码和/或使用我的实用类。

解决方案

下载最新的书籍源文件存档,解压缩并运行 Maven(参见配方 1.7)编译文件。

讨论

本书示例中使用的源代码包含在自 1995 年以来持续开发的几个源代码库中。这些列在表 1-2 中。

Table 1-2. 主要源代码库

仓库名称 GitHub 网址 包描述 大约大小
javasrc http://github.com/IanDarwin/javasrc Java 代码示例/演示 1,400 类
darwinsys-api http://github.com/Iandarwin/darwinsys-api 已发布的 API 200 类

您可以从表 1-2 中显示的 GitHub 网址下载这些仓库。GitHub 允许您下载整个仓库当前状态的 ZIP 文件,以及在 Web 界面上查看单个文件。使用 git clone 而不是作为存档进行下载更为推荐,因为您可以随时使用简单的 git pull 命令进行更新。鉴于这个代码库为当前 Java 版本的发布进行了大量更新,您肯定会发现书籍出版后有所变化。

如果您不熟悉 Git,请参阅 “CVS, Subversion, Git, Oh My!”。

javasrc

这是最大的仓库,主要包含用于展示特定功能或 API 的代码。文件按主题组织成子目录,其中许多与书籍章节大致对应,例如 strings 示例目录(第 3 章),regex 正则表达式目录(第 4 章),numbers 数字目录(第 5 章)等等。存档还包含按名称和按章节索引的文件,因此您可以轻松找到所需的文件。

javasrc 库进一步分解为十几个 Maven 模块(在表 1-3 中显示),这样你就不需要一直将所有依赖项放在 CLASSPATH 上。

Table 1-3. JavaSrc Maven 模块

目录/模块名称 描述
pom.xml Maven parent pom
Rdemo-web 使用 Web 框架的 R 演示
desktop AWT 和 Swing 相关内容(不再包含在Java Cookbook中)
ee 企业相关内容(不再包含在Java Cookbook中)
graal GraalVM 演示
jlink JLink 演示
json JSON 处理
main 包含大多数文件,即不需要因为 CLASSPATH 或其他问题而放在其他模块中的文件
restdemo REST 服务演示
spark Apache Spark 演示
testing 测试代码
unsafe Unsafe 类演示
xml XML 相关内容(不再包含在Java Cookbook中)

darwinsys-api

我收集了一些有用的内容,部分是通过将一些可重复使用的类从 javasrc 移到我的 API 中实现的,我在自己的 Java 项目中使用它。我在本书中使用它的示例代码,并将其导入到许多其他示例中。因此,如果你打算单独下载和编译示例,你应该先下载文件 darwinsys-api-1.x.jarx 的最新值)并将其包含在你的 CLASSPATH 中。请注意,如果你打算使用 Eclipse 或 Maven 构建 javasrc 代码,可以跳过此下载,因为顶级 Maven 脚本开始时包含了此 API 的 JAR 文件。

darwinsys-api 的编译 JAR 文件可在 Maven Central 获取;搜索 darwinsys 即可找到。当前的 Maven 构件如下:

<dependency>
   <groupId>com.darwinsys</groupId>
   <artifactId>darwinsys-api</artifactId>
   <version>1.1.3</version>
</dependency>

这个 API 包含大约两打 com.darwinsys 包,如 表 1-4 所示。其结构模糊地类似于标准 Java API;这是有意为之。这些包目前包括约 200 个类和接口。其中大多数都有 javadoc 文档,可与源码一起下载查看。

表 1-4. com.darwinsys 包

包名称 包描述
com.darwinsys.csv 处理逗号分隔值文件的类
com.darwinsys.database 通用数据库操作类
com.darwinsys.diff 比较工具
com.darwinsys.genericui 通用 GUI 组件
com.darwinsys.geo 国家代码、省/州等相关类
com.darwinsys.graphics 图形处理
com.darwinsys.html 处理 HTML 的类(目前只有一个)
com.darwinsys.io 使用 Java 底层 I/O 类的输入输出操作类
com.darwinsys.jsptags Java EE JSP 标签
com.darwinsys.lang 处理 Java 标准特性的类
com.darwinsys.locks 悲观锁定 API
com.darwinsys.mail 邮件处理类,主要是发送邮件的便捷类
com.darwinsys.model 样例数据模型
com.darwinsys.net 网络操作
com.darwinsys.preso 演示文稿
com.darwinsys.reflection 反射相关
com.darwinsys.regex 正则表达式工具:包含 REDemo 程序和一个 Grep 变种
com.darwinsys.security 安全相关
com.darwinsys.servlet Servlet API 辅助类
com.darwinsys.sql 处理 SQL 数据库的类
com.darwinsys.swingui 辅助构建和使用 Swing GUI 的类
com.darwinsys.swingui.layout 几个有趣的 LayoutManager 实现
com.darwinsys.testdata 测试数据生成器
com.darwinsys.testing 测试工具
com.darwinsys.unix Unix 辅助工具
com.darwinsys.util 几个杂项实用类
com.darwinsys.xml XML 工具

这本书中许多示例都使用这些类;只需查找以以下内容开头的文件即可:

package com.darwinsys;

您还会发现许多其他示例引用了来自com.darwinsys包的导入。

一般注意事项

您最好使用git clone下载两个 Git 项目的副本,然后每隔几个月使用git pull获取更新。或者,您可以从本书的目录页面下载两个库的单个交集子集,该子集几乎完全由实际在书中使用的文件组成。此存档是从格式化时动态包含到书中的源文件创建的,因此它应该完全反映您在书中看到的示例。但它不会包括三个单独存档中的那么多示例,也不能保证由于缺少依赖关系而编译所有内容,也不会经常更新。但是,如果您只想将片段复制到正在进行的项目中,这可能是您要获取的内容。您可以从我自己的本书网站找到所有这些文件的链接;只需跟随下载链接即可。

这两个单独的存储库包含多个独立项目,支持在 Eclipse(Recipe 1.3)和 Maven(Recipe 1.7)中构建。请注意,第一次在特定项目上调用 Maven 时,它会自动获取大量的先决条件库,因此请确保您在高速互联网链接上线。因此,Maven 将在构建之前确保安装所有先决条件。如果选择逐个构建各部分,请查看pom.xml文件中的依赖列表。如果您使用的工具不是 Eclipse 或包含在下载中的 Maven,则很遗憾我无法帮助您。

如果您使用的 Java 版本早于 Java 12,则有几个文件无法编译。您可以为已知无法编译的文件创建排除元素。

我在这两个项目中的所有代码都是根据最不限制性的仅信用许可证——两条款 BSD 许可证发布的。如果您发现它有用,请将其合并到您自己的软件中。无需写信询问我的许可;只需使用它,并署名。如果您因此变得富裕,请给我寄些钱。

提示

大多数命令行示例都涉及源文件,假设您在src/main/java目录中,并且可运行的类,假设您在(或已添加到您的CLASSPATH中)构建目录(例如,通常是target/classes)。每个示例都不会提到这一点,因为这样做会浪费很多纸张。

Caveat lector

这些仓库从 1995 年开始开发。这意味着你会发现一些不是最新的代码,或者不再反映最佳实践。这并不奇怪:如果其中任何部分不活跃地维护,任何代码库都会变旧。因此,在此时,我引用 Culture Club 的歌曲“Do You Really Want to Hurt Me”:“给我时间意识到我的错误。”当这本书中的建议与您在仓库中发现的某些代码不一致时,请记住这一点。极限编程的一个实践是持续重构,即随时改进代码库的任何部分。如果在线源目录中的代码与书中的不同,请不要感到惊讶;我几乎每个月都会对代码进行一些改进,并且结果经常被提交和推送。所以如果书中打印的内容与您从 GitHub 获取的内容有所不同,请高兴,而不是难过,因为您将受益于前瞻性。此外,人们可以通过拉取请求轻松在 GitHub 上做出贡献;这就是它变得有趣的地方。如果您发现错误或改进,请向我发送拉取请求!本书页面上的综合档案不会经常更新。

1.7 使用 Apache Maven 自动处理依赖关系、编译、测试和部署

问题

您希望有一个自动执行所有操作的工具:下载您的依赖项,编译您的代码,编译和运行您的测试,打包应用程序,并安装或部署它。

解决方案

使用 Apache Maven。

讨论

Maven 是一个以 Java 为中心的构建工具,它包括一个复杂的、分布式的依赖管理系统,同时也提供了构建应用程序包(如 JAR、WAR 和 EAR 文件)和将其部署到各种不同目标的规则。而老的构建工具关注于如何构建,Maven 文件关注于做什么,指定你想要做什么。

Maven 由一个名为 pom.xml(项目对象模型)的文件控制。一个示例 pom.xml 可能如下所示:

<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  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.example</groupId>
  <artifactId>my-se-project</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>

  <name>my-se-project</name>
  <url>http://com.example/</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.8.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

这指定了一个名为 my-se-project(标准版项目)的项目,将打包成一个 JAR 文件;它依赖于 JUnit 4.x 框架进行单元测试(参见 Recipe 1.10),但仅需要在编译和运行测试时。如果我在具有此 POM 文件的目录中键入 mvn install,Maven 将确保我有给定版本的 JUnit 的副本(以及任何 JUnit 依赖的内容)。然后,它将编译所有内容(为编译器设置 CLASSPATH 和其他选项),运行所有单元测试,如果所有测试通过,则为程序生成一个 JAR 文件。然后,它将安装它在我的个人 Maven 仓库(位于 ~/.m2/repository),以便其他 Maven 项目可以依赖于我的新项目的 JAR 文件。请注意,我不需要告诉 Maven 源文件的位置,也不需要告诉它如何编译它们——这一切都由合理的默认值处理,基于良好定义的项目结构。程序源代码预期在 src/main/java 中找到,测试在 src/test/java 中找到;如果是 Web 应用程序,则默认情况下 Web 根目录预期在 src/main/webapp 中。当然,您可以覆盖这些设置。

注意,即使前面的配置文件也不必手工编写;Maven 的原型生成规则允许它构建几百种项目类型的初始版本。下面是文件的创建方式:

$ mvn archetype:generate \
    -DarchetypeGroupId=org.apache.maven.archetypes \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DgroupId=com.example -DartifactId=my-se-project

[INFO] Scanning for projects...
Downloading: http://repo1.maven.org/maven2/org/apache/maven/plugins/
    maven-deploy-plugin/2.5/maven-deploy-plugin-2.5.pom
[several dozen or hundred lines of downloading POM files and Jar files...]
[INFO] Generating project in Interactive mode
[INFO] Archetype [org.apache.maven.archetypes:maven-archetype-quickstart:1.1]
    found in catalog remote
[INFO] Using property: groupId = com.example
[INFO] Using property: artifactId = my-se-project
Define value for property 'version':  1.0-SNAPSHOT: :
[INFO] Using property: package = com.example
Confirm properties configuration:
groupId: com.example
artifactId: my-se-project
version: 1.0-SNAPSHOT
package: com.example
 Y: : y
[INFO] ------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Old (1.x) Archetype:
    maven-archetype-quickstart:1.1
[INFO] ------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: com.example
[INFO] Parameter: packageName, Value: com.example
[INFO] Parameter: package, Value: com.example
[INFO] Parameter: artifactId, Value: my-se-project
[INFO] Parameter: basedir, Value: /private/tmp
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] project created from Old (1.x) Archetype in dir: /private/tmp/
    my-se-project
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6:38.051s
[INFO] Finished at: Sun Jan 06 19:19:18 EST 2013
[INFO] Final Memory: 7M/81M
[INFO] ------------------------------------------------------------------------

或者,您可以执行 mvn archetype:generate,并从一个相当长的选择列表中选择默认选项。默认选项是一个快速启动的 Java 原型,可以轻松入门。

IDE(参见 Recipe 1.3)支持 Maven。例如,如果您使用 Eclipse,M2Eclipse(m2e)是一个 Eclipse 插件,可以根据 POM 文件构建您的 Eclipse 项目依赖项;该插件默认随当前 Java 开发者版的 Eclipse 发货。它也适用于一些旧版本;请参阅 Eclipse 网站 获取插件详细信息。

POM 文件可以重新定义任何标准目标。常见的 Maven 目标(默认预定义为执行合理的操作)包括以下内容:

clean

移除所有生成的构件

compile

编译所有源文件

test

编译并运行所有单元测试

package

构建包

install

pom.xml 和包安装到您本地的 Maven 仓库,以供其他项目使用

deploy

尝试安装包(例如,在应用服务器上)

大多数步骤都会隐式地调用前面的步骤。例如,package 将编译任何缺失的 .class 文件,并在此次运行中运行测试(如果尚未完成)。

POM 文件中有一个可选的 distributionManagement 元素或命令行上的 -DaltDeploymentRepository,用于指定备用的部署位置。 应用服务器供应商提供了特定于应用服务器的目标;例如,使用 WildFly 应用服务器(十多年前称为 JBoss AS),您可以按照其文档安装一些额外的插件,然后使用该应用服务器进行部署。

mvn wildfly:deploy

而不是常规部署。由于我经常使用这个 Maven 咒语,我有一个 shell 别名或批处理文件 mwd 来自动化甚至那个。

Maven 的优缺点

Maven 可以处理复杂的项目并且非常可配置。 我使用 Maven 构建 darwinsys-apijavasrc 项目,并让其处理找到的依赖关系,使项目源代码的下载变小(实际上,将下载开销移到项目本身的服务器上)。 Maven 的唯一真正缺点是需要一段时间来完全掌握它,并且当出现问题时很难进行诊断。 当事情失败时,一个好的网络搜索引擎是您的朋友。

我担心的一个问题是,黑客可能会访问项目的站点并修改或安装 POM 的新版本。 Maven 会自动获取更新的 POM 版本。 但是,在下载过程中它确实使用哈希签名来验证文件未被篡改,并且所有要上传的文件必须使用 PGP/GPG 签名,因此攻击者必须同时破坏上传帐户和签名密钥。 虽然我并不知道这种情况曾经发生过。

参见

http://maven.apache.org开始。

1.8 使用 Gradle 自动化依赖项、编译、测试和部署

问题

您希望一个构建工具不要求您在配置文件中使用大量的 XML。

解决方案

使用 Gradle 的简单构建文件格式和按约定的配置,可以实现更短的构建文件和快速的构建。

讨论

Gradle 是构建工具(Make、Ant 和 Maven)的最新继任者。 Gradle 自称为“企业自动化工具”,并与其他构建工具和 IDE 集成。

与其他基于 Java 的工具不同,Gradle 不使用 XML 作为其脚本语言,而是使用基于 JVM 和基于 Java 的脚本语言Groovy的领域特定语言(DSL)。

你可以通过从Gradle 网站下载并解压 ZIP 文件,将其 bin 子目录添加到路径中来安装 Gradle。

然后,您可以开始使用 Gradle。 假设您使用标准的源目录(src/main/javasrc/main/test),该目录被 Maven 和 Gradle 等工具共享,在 Example 1-1 中的示例 build.gradle 文件将构建您的应用程序并运行单元测试。

示例 1-1. 示例 build.gradle 文件
# Simple Gradle Build for the Java-based DataVis project
apply plugin: 'java'
# Set up mappings for Eclipse project too
apply plugin: 'eclipse'

# The version of Java to use
sourceCompatibility = 11
# The version of my project
version = '1.0.3'
# Configure JAR file packaging
jar {
    manifest {
        attributes 'Main-class': 'com.somedomainnamehere.data.DataVis',
        'Implementation-Version': version
    }
}

# optional feature: like -Dtesting=true but only when running tests ("test task")
test {
    systemProperties 'testing': 'true'
}

您可以通过添加以下行到您的build.gradle文件中,来启动行业在 Maven 基础设施上的巨大投资:

# Tell Gradle to look in Maven Central
repositories {
    mavenCentral()
}

# We need darwinsys-api for compiling as well as JUnit for testing
dependencies {
    compile group: 'com.darwinsys', name: 'darwinsys-api', version: '1.0.3+'
    testCompile group: 'junit', name: 'junit', version: '4.+'
}

参见

Gradle 中还有更多功能。从Gradle 的网站开始,查看文档

1.9 处理废弃警告

问题

您的代码曾经可以干净地编译,但现在出现了废弃警告。

解决方案

您可能已经忽略了。要么带着这些警告——危险地——生活,要么修改您的代码以消除它们。

讨论

每个新版本的 Java 都包含大量强大的新功能,但代价是:在这些新功能的演变过程中,Java 的维护者们发现了一些旧功能存在问题,因此不应再使用,因为它们无法真正修复。例如,在第一个主要修订版中,他们意识到java.util.Date类在国际化方面存在严重的限制。因此,Date类的许多方法和构造函数都被标记为“不推荐使用”。根据美国传统词典的定义,废弃(deprecate)意味着“表示不赞成;谴责”。因此,Java 的开发者们不赞成旧的做法。尝试编译以下代码:

import java.util.Date;

/** Demonstrate deprecation warning */
public class Deprec {

    public static void main(String[] av) {

        // Create a Date object for May 5, 1986
        @SuppressWarnings("deprecation")
        // EXPECT DEPRECATION WARNING without @SuppressWarnings
        Date d = new Date(86, 04, 05);
        System.out.println("Date is " + d);
    }
}

发生了什么?当我编译它时(在添加@SuppressWarnings()注解之前),我得到了这个警告:

C:\javasrc>javac Deprec.java
Note: Deprec.java uses or overrides a deprecated API.  Recompile with
"-deprecation" for details.
1 warning
C:\javasrc>

因此,我们遵循指示。有关详细信息,请使用-deprecation重新编译以查看其他细节:

C:\javasrc>javac -deprecation Deprec.java
Deprec.java:10: warning: constructor Date(int,int,int) in class java.util.Date
has been deprecated
                Date d = new Date(86, 04, 05);          // May 5, 1986
                         ^
1 warning

C:\javasrc>

警告很简单:接受三个整数参数的Date构造函数已被废弃。如何修复它?答案通常是查阅该类的 javadoc 文档。Date页面的介绍部分如下:

Date类表示时间的特定时刻,精确到毫秒。

在 JDK 1.1 之前,Date类具有两个额外的功能。它允许将日期解释为年、月、日、小时、分钟和秒的值。它还允许格式化和解析日期字符串。不幸的是,这些功能的 API 不适合国际化。从 JDK 1.1 开始,应使用Calendar类在日期和时间字段之间进行转换,并使用DateFormat类格式化和解析日期字符串。Date中对应的方法已被废弃。

更具体地说,在描述接受三个整数参数的构造函数时,Date的 javadoc 如下所示:

Date(int year, int month, int date)

废弃。自 JDK 版本 1.1 起,被Calendar.set(year + 1900, month, date)GregorianCalendar(year + 1900, month, date)所取代。

当然,旧的Date类已被LocalDateLocalDateTime所取代(参见第六章),因此您只会在遗留代码中看到这个特定的例子,但处理废弃警告的原则很重要,因为许多 Java 的新版本都会对以前“可用”的 API 部分添加废弃警告。

一般来说,当某些东西被弃用时,你不应该在任何新代码中使用它;而在维护代码时,应努力消除弃用警告。

除了Date(Java 8 包含一个全新的日期/时间 API;参见第六章)之外,在标准 API 中被弃用警告的主要领域是古老的事件处理和一些方法(其中一些很重要)在Thread类中。

当你想出更好的方法来做事情时,你也可以弃用自己的代码。在你希望弃用的类或方法之前立即放置一个@Deprecated注解和/或在 javadoc 注释中使用一个@deprecated标签(参见 Recipe 15.2)。javadoc 注释允许你解释弃用,而注解对于一些工具更容易识别,因为它在运行时存在(所以你可以使用反射;参见第十七章)。

参见

许多其他工具对你的 Java 代码执行额外的检查。请参阅我的Java 程序检查网站。

1.10 使用单元测试维护代码正确性:JUnit

问题

你不想调试你的代码。

解决方案

使用单元测试在开发每个类时验证它。

讨论

停止使用调试器是耗时的,而在发布的代码中找到错误则更糟糕!最好事先测试。单元测试方法论已经存在很长时间了;这是一种可靠的方法,可以将代码分成小块进行测试。通常,在像 Java 这样的面向对象语言中,单元测试是应用于单个类的,与系统或集成测试相反,在系统或集成测试中会测试完整的切片甚至整个应用程序。

我长期以来一直是这种非常基本的测试方法的支持者。确实,被称为极限编程(简称 XP)的软件方法论的开发者倡导测试驱动开发(TDD):在编写代码之前编写单元测试。他们还主张几乎每次构建应用程序时都运行测试。他们提出了一个很好的问题:如果你没有测试,你怎么知道你的代码(还)能工作?这个单元测试倡导者群体有一些著名的领导者,包括因设计模式而著名的 Erich Gamma 和因eXtreme Programming而著名的 Kent Beck(都是 Addison-Wesley 的作者)。我绝对支持他们对单元测试的倡导。

实际上,我的许多类过去都附带“内置”单元测试。那些不是其自身主程序的类通常会包含一个 main 方法,该方法仅测试或至少练习类的功能。令我惊讶的是,在遇到 XP 之前,我经常认为我经常这样做,但实际检查了两个项目后发现,只有大约三分之一的类有测试用例,无论是内部还是外部。显然需要的是一种统一的方法论。这由 JUnit 提供。

JUnit 是一个以 Java 为中心的提供测试用例的方法论,可以免费下载。它是一个非常简单但有用的测试工具。它易于使用 — 您只需编写一个测试类,其中包含一系列方法,并用 @Test 注解它们(较旧的 JUnit 3.8 要求测试方法的名称以 test 开头)。JUnit 使用内省(参见 Chapter 17)查找所有这些方法,然后为您运行它们。JUnit 的扩展处理各种任务,如负载测试和测试企业组件;JUnit 网站提供了这些扩展的链接。所有现代 IDE 都提供内置支持来生成和运行 JUnit 测试。

如何开始使用 JUnit?只需编写一个测试就可以了。这里我已经写了一个简单的测试我的 Person 类,并将它放在一个名为 PersonTest 的类中(请注意显而易见的命名模式):

public class PersonTest {

    @Test
    public void testNameConcat() {
        Person p = new Person("Ian", "Darwin");
        String f = p.getFullName();
        assertEquals("Name concatenation", "Ian Darwin", f);
    }
}

JUnit 4 已经存在很长时间并且运行良好。JUnit 5 只有几年历史并且有一些改进。像这样的简单测试 PersonTest 类在 JUnit 4 或 5 中是相同的(但导入不同)。使用额外功能,如设置方法在每个测试之前运行,需要在 JUnit 4 和 5 之间使用不同的注解。

要显示手动运行 PersonTest,我编译测试并调用命令行测试工具 TestRunner

$ javac PersonTest.java
$ java -classpath .:junit4.x.x.jar junit.textui.TestRunner testing.PersonTest
.
Time: 0.188

OK (1 tests)

$

在实际应用中,以这种方式运行测试非常繁琐,因此我只是将测试放在标准目录结构中(即 src/test/java/),与被测试的代码包相同,并运行 Maven(参见 Recipe 1.7),它会自动编译和运行所有单元测试,并在任何测试失败时中止构建,每次尝试构建、打包或部署应用时。Gradle 也会这样做。

所有现代 IDE 都提供内置支持来运行 JUnit 测试;在 Eclipse 中,您可以在 Package Explorer 中右键单击项目,然后选择 Run As→Unit Test,它会找到并运行整个项目中的所有 JUnit 测试。MoreUnit 插件(在 Eclipse Marketplace 免费提供)旨在简化测试的创建和运行。

Hamcrest matchers 允许您编写更具表达力的测试,但需要额外下载。在 JUnit 4 中内置了支持它们的 assertThat 静态方法,但您需要从 Hamcrest 下载匹配器或通过 Maven 构件获取。

这里是使用 Hamcrest 匹配器的一个例子:

public class HamcrestDemo {

    @Test
    public void testNameConcat() {
        Person p = new Person("Ian", "Darwin");
        String f = p.getFullName();
        assertThat(f, containsString("Ian"));
        assertThat(f, equalTo("Ian Darwin"));
        assertThat(f, not(containsString("/"))); // contrived, to show syntax
    }
}

参见

JUnit 本身提供了大量的文档;可以从之前列出的网站下载。

Java 的另一个备选单元测试框架是TestNG;它通过采用诸如 Java 注解等功能而获得了一些早期的吸引力,但自从 JUnit 跟随注解程序后,它仍然是 Java 单元测试的主要包。

另一个感兴趣的包是AssertJ,它似乎提供了与 JUnit 结合使用的 Hamcrest 相似的功能强大的能力。

最后,人们经常需要创建替代对象来供被测试的类使用(被测试类的依赖项)。虽然你可以手工编写这些,但通常我鼓励使用诸如Mockito这样的包,它可以动态生成模拟对象,让这些模拟对象提供固定的返回值,验证依赖项是否被正确调用等功能。

记住:尽早、经常地进行测试!

1.11 使用持续集成维护你的代码

问题

你希望确保整个代码库定期编译并通过其测试。

解决方案

使用像 Jenkins/Hudson 这样的持续集成服务器。

讨论

如果你之前没有使用过持续集成(Continuous Integration,CI),你会想知道在没有它的情况下是如何运作的。CI 简单来说就是让项目中的所有开发者定期地集成(比如提交)他们的变更到项目源代码的一个单一主副本中,然后构建和测试项目,确保它仍然能正常工作并通过其测试。这可能是每天几次,或者每几天一次,但不应该更频繁,否则集成可能会遇到更大的障碍,因为多个开发者可能已修改了同一文件。

但并不只是大型项目从 CI 中受益。即使在一个人的项目中,拥有一个可以点击的单一按钮来检出所有最新版本的东西,编译它,链接或打包它,运行所有自动化测试,并给出红色或绿色的通过/失败指示器也是非常棒的。更好的是,它可以每天自动执行,甚至在每次提交到主分支时都可以自动执行。

不仅仅是基于代码的项目从 CI 中受益。如果你有多个小型网站,将它们全部纳入 CI 控制是朝着围绕网站部署和管理开发自动化、DevOps 文化的几个重要步骤之一。

如果你对 CI 的概念还很陌生,我无法做得比恳请你阅读马丁·福勒(Martin Fowler)的深思熟虑的(正如他一直以来所做的)关于这个主题的论文更好了。其中一个关键点是自动化代码的管理以及构建项目所需的所有其他工件,并自动化构建实际过程,可能使用本章前面讨论过的其中一个构建工具。¹

在 CI 服务器中有许多免费和商业选择。在开源世界中,CruiseControl 和 Jenkins/Hudson² 是你自己部署的最知名的 CI 服务器之一。还有托管解决方案,如 Travis CITeamCity,或 CircleCI。这些托管解决方案消除了设置和运行自己的 CI 服务器的需要。它们也倾向于在你的存储库中有其配置(如 travis.yml 等),因此向它们部署变得更加简单。

Jenkins 作为一个 Web 应用程序运行,可以在 Jakarta EE 服务器内或者作为独立的 Web 服务器运行。一旦它启动了,你可以使用任何标准的 Web 浏览器作为其用户界面。安装和启动 Jenkins 可以像解压分发文件和调用如下所示那样简单:

java -jar jenkins.war

这将启动自己的一个小型 Web 服务器。如果你这样做,请确保配置安全性,如果你的机器可以从互联网访问到!

许多人发现将 Jenkins 运行在一个完整功能的 Java EE 或 Java Web 服务器中更安全;从 Tomcat 到 JBoss 再到 WebSphere 或 Weblogic,都可以完成这项工作并让你施加额外的安全约束。

一旦 Jenkins 启动并运行,并且你已经启用了安全性并且以具有足够权限的帐户登录,你可以创建任务。一个任务通常对应一个项目,无论是源代码检出(一个源代码检出)还是结果(一个 .war 文件,一个可执行文件,一个库,一个任何东西)。设置一个项目就像在仪表板左上角点击“新建任务”按钮那样简单,如 图 1-6 所示。

你可以填写前几个信息:项目的名称和简要描述。请注意,每个输入字段旁边都有一个问号图标,这将在你填写时给你提示。不要害怕偷看这些提示! 图 1-7 显示了设置新任务的前几个步骤。

在表单的接下来几个部分中,Jenkins 使用动态 HTML 来根据你选择的内容显示输入字段。我的演示项目“TooSmallToFail”开始时没有源代码管理(SCM)仓库,但你的真实项目可能已经在 Git、Subversion 或其他 SCM 中。如果你的 SCM 没有在列表中,不要担心;有数百个插件可以处理几乎任何 SCM。一旦选择了你的 SCM,你将会进入到提取项目源代码的参数设置页面,使用文本字段请求 SCM 需要的具体信息:Git 的 URL,CVS 的 CVSROOT 等等。

jcb4 0106

图 1-6. Jenkins 仪表板

jcb4 0107

图 1-7. 在 Jenkins 中创建一个新任务

您还需要告诉 Jenkins 何时如何 构建(以及打包、测试、部署…)您的项目。对于何时,您有几种选择,例如在另一个 Jenkins 项目之后构建,根据类似 cron 的定时表定期构建,或者根据轮询 SCM 以查看是否有任何更改(使用相同的 cron 样式调度程序)。如果您的项目位于 GitHub(而不仅仅是本地 Git 服务器)或其他某些 SCM 上,则可以在有人将更改推送到存储库时构建项目。这完全取决于找到合适的插件并按照其文档进行操作。

接下来是how,即构建过程。同样,在 Jenkins 中包含了几种构建类型,并且还有许多插件可供选择:我使用过 Apache Maven、Gradle、传统的 Unix make工具,甚至是 shell 或命令行。与之前一样,一旦选择了工具,将会显示特定于所选工具的文本字段。在玩具示例中,TooSmallToFail,我只是使用 shell 命令/bin/false(这在任何 Unix 或 Linux 系统上都应该存在),以确保项目实际上无法构建,这样您就可以看到其表现如何。

您可以拥有零个或多个构建步骤;只需不断点击添加按钮并添加额外的步骤,如图 1-8 所示。

jcb4 0108

图 1-8. 在 Jenkins 中配置 SCM 并添加构建步骤

一旦您认为已输入所有必要信息,请点击页面底部的保存按钮,您将返回到项目的主页面。在这里,您可以点击最左边的有趣的小“立即构建”图标来启动构建。或者,如果您设置了构建触发器,您可以等待它们启动;但再次,您是否宁愿立即知道是否做得恰到好处?图 1-9 显示构建正在开始。

如果作业未能构建,您将看到一个红色的球而不是绿色的。实际上,默认情况下,成功的构建显示为蓝色球(日本交通灯中的go灯泡是蓝色而不是绿色,Kohsuke 居住的地方),但大多数人在日本以外地区更喜欢绿色来表示成功,因此可选的 Green Balls 插件通常是新安装中首先添加的插件之一。

除了红色或绿色的球,您还会看到一个天气报告,从晴朗(最近几次构建成功)到多云、雨天或暴风雨(最近没有构建成功)不等。

点击失败的项目链接,然后点击控制台输出的链接,找出问题所在。通常的工作流程是对项目进行更改,将其提交/推送到源代码库,并再次运行 Jenkins 构建。

jcb4 0109

图 1-9. 在 Jenkins 中添加新作业后

Jenkins 有数百个可选插件。为了让您的生活更轻松,几乎所有这些插件都可以通过点击“管理 Jenkins”链接,然后转到“管理插件”来安装。可用选项卡列出了来自 Jenkins.org 的所有可用插件;您只需勾选您想要的插件旁边的复选框,然后点击应用。您也可以在那里找到更新。如果您的插件添加或升级需要重新启动,则会看到一个黄色球和相关的字样;否则,您应该看到一个表示插件成功的绿色(或蓝色)球。您还可以直接在网上查看插件列表:链接

我提到过 Jenkins 最初以 Hudson 的名字开始。Hudson 项目仍然存在,并托管在 Eclipse 网站上。据我上次检查,两个项目都保持了插件的兼容性,因此一个项目中的许多或大多数插件可以与另一个项目一起使用。事实上,最受欢迎的插件在两个项目的可用选项卡中都有,并且关于 Jenkins 的这个配方所说的大部分内容同样适用于 Hudson。如果您使用不同的 CI 系统,则需要检查该系统的文档,但概念和好处将是相似的。

1.12 获取可读的堆栈跟踪

问题

在运行时,您会得到一个异常堆栈跟踪,但其中大部分重要部分没有行号。

解决方案

确保已启用调试编译。

讨论

当 Java 程序引发异常时,异常会沿调用堆栈传播,直到找到一个匹配的 catch 子句。如果找不到,则调用了您的 main() 方法的 Java 解释程序会捕获异常并打印堆栈跟踪,显示从程序顶部到引发异常的地方的所有方法调用。您可以在任何 catch 子句中打印此跟踪:Throwable 类有几个名为 printStackTrace() 的方法重载。

跟踪包含行号仅在编译时包含。在使用 javac 时,这是默认设置。如果添加了 -g 选项,javac 还将在编译代码中包含局部变量名称和其他信息,这将使得在崩溃事件中获得更好的调试信息。

1.13 查找更多 Java 源代码

问题

你想构建一个大型应用程序,需要尽量减少编码,避免“自行发明”综合症。

解决方案

使用源码,卢克。有数千个 Java 应用程序、框架和库可供使用。

讨论

Java 源代码随处可见。如前所述,本书的所有代码示例都可以下载:参见 Recipe 1.6。

另一个有价值的资源是 Java API 的源代码。你可能没有意识到,但 Java API 所有公共部分的源代码都包含在每个 Java 开发工具包的发布版中。想知道java.util.ArrayList是如何工作的?你有源代码。在制作JTable行为时遇到问题?标准 JDK 包含所有公共类的源代码!寻找一个名为src.zipsrc.jar的文件;有些版本会解压缩它,有些不会。

如果这还不够,你可以免费通过互联网获取整个 JDK 的源代码,可以通过openjdk.java.net的 Mercurial 源代码库管理员或AdoptOpenJDK 在github.com的 Git 镜像。这包括公共和非公共 API 部分的源代码,以及编译器(用 Java 编写)和大量用 C/C++编写的代码(运行时本身和与本机库的接口)。例如,java.io.Reader有一个名为read()的方法,它从文件或网络连接读取数据字节。这个方法的每个操作系统版本都有用 C 语言编写,因为它调用 Unix、Windows、macOS 或其他操作系统的read()系统调用。JDK 源代码包括所有这些东西的源代码。

1.14 寻找可运行的 Java 库

问题

你希望重用已发布的库,而不是重新发明手头问题的众所周知的解决方案。

解决方案

利用互联网寻找可重用的软件。

讨论

尽管本书的大部分内容都是关于编写 Java 代码,但本节是关于编写代码,而是使用他人编写的代码。有数百个优秀的框架可用于增强你的 Java 应用程序——为什么要重新发明轮子,当你可以买一个完美圆的?许多这些框架已经存在多年,并通过用户的反馈变得非常成熟。

什么是库和框架之间的区别?有时候这个区分有些模糊,但总体来说,框架是一个带有你需要填充的空白的程序,而库是你调用的代码。这大致相当于通过购买一个几乎完整但没有引擎的汽车来建造汽车,与购买所有零件并自己将它们螺栓在一起建造汽车的区别。

在考虑使用第三方框架时,有许多选择和需要考虑的问题。其中一个是成本,涉及开源与闭源的问题。大多数开源工具可以免费下载和使用,要么没有任何条件,要么有你必须遵守的条件。在这里没有足够的空间来讨论这些许可问题,因此我会推荐你阅读《理解开源和自由软件许可证》(O’Reilly)。

大量开源软件以编译库的形式在 Maven 中央仓库上提供,详见 “Maven Central: Mapping the World of Java Software”。

以下是列出的一些知名的 Java 开源框架和库,详见 Table 1-5。这些站点上的大多数项目都经过了社区审查,即经过某种形式的社区过程评判和认可。

Table 1-5. Java 可信赖的开源集合

组织 URL 备注
Apache 软件基金会 http://projects.apache.org 不仅仅是一个网页服务器!
Eclipse 软件基金会 https://eclipse.org/projects IDE 的家园,也是 Jakarta EE 的主场
Spring 框架 http://spring.io/projects 包含数十个框架:Spring IOC(DI 工厂)、Spring MVC(Web)、等等
JBoss 社区 https://redhatofficial.github.io/ 列出了他们的数个项目,以及他们当前使用和/或支持的许多开源项目。
Codehaus 见脚注^(a)
^(a) Codehaus 几年前已经下线。截至 2019 年,该域名归 Apache 软件基金会所有,但不再响应浏览器请求。GitHub 上仍有 Codehaus 的帐户,其中包含一些此前在 Codehaus 上的项目,有些活跃,有些不活跃。详见 本文 了解 Codehaus 的历史。

此外,还有各种开源代码库,这些库未经过审查,任何注册用户都可以在那里创建项目,无论现有社区的大小如何(如果有的话)。成功的站点积累了太多的项目,无法在单个页面列出它们 —— 您必须搜索。大多数并非专门针对 Java。Table 1-6 展示了一些开源代码库。

Table 1-6. 开源代码库

名称 URL 备注
Sourceforge.net https://sourceforge.net/ 最古老之一
GitHub http://github.com/ “社交编码”;目前可能是使用最广泛的,现在由微软拥有
Bitbucket https://bitbucket.org/ 公共和私有仓库;免费和付费计划
GitLab https://gitlab.org/ 公共和私有仓库;免费和付费计划
Maven 中央仓库 https://search.maven.org/ 每个项目均有编译 jar、源码 jar 和 javadoc jar

我并不是要贬低这些代码库 —— 实际上,本书的演示程序集合托管在 GitHub 上。我只是在说,你必须知道自己在寻找什么,并在决定使用框架之前要多加小心。它周围是否有一个社区,或者它是否是一条死胡同?

我维护着一个小的 Java 网站,可能会对你有所帮助。它包括了 Java 资源列表以及与本书相关的资料。

对于 Java 企业或 Web 层,有两个主要框架也提供了依赖注入:第一个是 JavaServer Faces (JSF) 和 CDI,第二个是 Spring 框架的 SpringMVC 包。JSF 和内置的 CDI(上下文和依赖注入)提供了 DI 以及一些额外的上下文,比如一个非常有用的 Web 对话上下文,它在多个网页交互中保存对象。Spring 框架提供了依赖注入和 SpringMVC Web 层辅助类。Table 1-7 显示了一些 Web 层资源。Spring MVC 和 JSF 远非唯一的 Web 框架;Table 1-7 中的列表包括许多其他框架,它们可能更适合你的应用程序。你必须决定!

表 1-7. Web 层资源

名称 URL 注释
Ian’s List of 100 Java Web Frameworks http://darwinsys.com/jwf/
JSF http://www.oracle.com/technetwork/java/javaee/overview/ 用于网页的 Java EE 标准技术

因为 JSF 是一个基于组件的框架,有许多附加组件可以使你的基于 JSF 的网站比默认的 JSF 组件更加功能强大(和更好看)。Table 1-8 显示了一些 JSF 的附加库。

表 1-8. JSF 附加库

名称 URL 注释
BootsFaces https://bootsfaces.net/ 将 BootStrap 与 JSF 结合
ButterFaces http://butterfaces.org/ 丰富的组件库
ICEfaces http://icefaces.org/ 丰富的组件库
OpenFaces http://openfaces.org/ 丰富的组件库
PrimeFaces http://primefaces.org/ 丰富的组件库
RichFaces http://richfaces.org/ 丰富的组件;不再维护
Apache DeltaSpike http://deltaspike.apache.org/ JSF 的众多代码附加功能
JSFUnit http://www.jboss.org/jsfunit/ JSF 的 JUnit 测试
OmniFaces http://omnifaces.org/ JSF 实用工具附加

现在几乎每件事情都有框架和库。如果我的列表不能带你找到你需要的东西,网上搜索可能会有所帮助。尽量不要重复发明轮子!

就像所有免费软件一样,请确保您理解各种许可方案的后果。例如,GPL 下的代码会自动将 GPL 转移到使用其中任何一小部分的代码上。请咨询律师。结果可能有所不同。尽管存在这些警告,源代码对于想要深入了解 Java 的人来说是一种无价的资源。

¹ 如果部署或构建包括“让史密斯在他的桌面上处理文件 X 并复制到服务器”的步骤,那么您可能还不太理解自动化测试的概念。

² JenkinsHudson 最初是 Hudson,由小林克树在 Sun Microsystems 工作时编写的。后来发生了文化冲突,导致 Jenkins 从 Hudson 中分离出来,创建了项目的一个新分支。小林克树现在致力于后来被称为 Jenkins 的部分。我会一直使用 Jenkins 这个名字,因为这是我使用的名字,而且每次都说“Jenkins/Hudson”太长了。但几乎这里的所有内容也适用于 Hudson。

第二章:与环境交互

2.0 引言

本章描述了你的 Java 程序如何处理其即时环境,即我们所称的运行时环境。在某种意义上,几乎使用任何 Java API 在 Java 程序中进行的所有操作都涉及环境。在这里,我们更专注于直接围绕你的程序的事物。在这个过程中,我们将介绍 System 类,它对你的特定系统了解甚深。

另外两个运行时类值得简要提及。第一个是 java.lang.Runtime,它在 System 类的许多方法背后起作用。例如,System.exit() 只是调用 Runtime.exit()Runtime 在技术上属于环境的一部分,但我们直接使用它的唯一时机是运行其他程序,这在 Recipe 18.1 中有涵盖。

2.1 获取环境变量

问题

你希望从你的 Java 程序内部获取环境变量的值。

解决方案

使用 System.getenv()

讨论

1979 年发布的 Unix 第七版引入了一个称为环境变量的新功能。环境变量在所有现代 Unix 系统(包括 macOS)和大多数后来的命令行系统(如 Windows 中的 DOS 或命令提示符)中都存在,但在一些旧平台或其他 Java 运行时中并不存在。环境变量通常用于自定义个人计算机用户的运行时环境,因此得名。举一个熟悉的例子,在 Unix 或 DOS 上,环境变量 PATH 决定系统查找可执行程序的位置。因此,人们想知道如何从他们的 Java 程序中访问环境变量。

答案是,在所有现代版本的 Java 中都可以这样做,但是应该谨慎依赖能够指定环境变量,因为一些罕见的操作系统可能不提供它们。尽管如此,你不太可能遇到这样的系统,因为所有“标准”桌面系统目前都提供它们。

在一些古老版本的 Java 中,System.getenv() 被弃用或者根本无效。如今,getenv() 方法不再被弃用,尽管仍然警告应该使用系统属性(参见 Recipe 2.2)。即使在支持环境变量的系统中,它们的名称在某些平台上是大小写敏感的,在其他平台上则是不敏感的。Example 2-1 中的代码是一个使用 getenv() 方法的简短程序。

示例 2-1. main/src/main/java/environ/GetEnv.java
public class GetEnv {
    public static void main(String[] argv) {
        System.out.println("System.getenv(\"PATH\") = " + System.getenv("PATH"));
    }
}

运行此代码将产生类似以下的输出:

C:\javasrc>java environ.GetEnv
System.getenv("PATH") = C:\windows\bin;c:\jdk1.8\bin;c:\documents
    and settings\ian\bin
C:\javasrc>

System.getenv() 方法的无参数形式以不可变的 String Map 形式返回所有环境变量。你可以遍历这个映射并访问所有用户的设置或检索多个环境设置。

getenv() 的两种形式都要求您具有访问环境的权限,因此它们通常不适用于受限制的环境,例如小程序。

2.2 从系统属性中获取信息

问题

您需要从系统属性中获取信息。

解决方案

使用 System.getProperty()System.getProperties()

讨论

什么是 属性 呢?属性只是存储在 java.util.Properties 对象中的名称和值对,我们在 Recipe 7.10 中会更详细地讨论它。

System.Properties 对象控制并描述了 Java 运行时。System 类有一个静态的 Properties 成员,其内容是操作系统特定部分(例如 os.name)、系统和用户定制部分(java.class.path)以及在命令行上定义的属性(我们稍后会看到)。请注意,这些名称中的句点(例如 os.archos.versionjava.class.pathjava.lang.version)使其看起来好像存在类似于包/类名称的层次关系。然而,Properties 类并不强制这样的关系:每个键只是一个字符串,句点并不特殊。

要查看所有定义的系统属性,可以通过调用 System.getProperties() 的输出进行迭代,就像在 Example 2-2 中一样。

例子 2-2. jshell System.getProperties()
jshell> System.getProperties().forEach((k,v) -> System.out.println(k + "->" +v))
awt.toolkit->sun.awt.X11.XToolkit
java.specification.version->11
sun.cpu.isalist->
sun.jnu.encoding->UTF-8
java.class.path->.
java.vm.vendor->Oracle Corporation
sun.arch.data.model->64
java.vendor.url->http://java.oracle.com/
user.timezone->
os.name->OpenBSD
java.vm.specification.version->11
... many more ...
jshell>

请记住,以“sun”开头的属性是不受支持且可能会更改的。

要检索一个系统提供的属性,使用 System.getProperty(propName)。如果我只想知道 System Properties 是否有一个名为 "pencil_color" 的属性,我可以这样说:

        String sysColor = System.getProperty("pencil_color");

那么它返回什么呢?当然,Java 并不聪明到知道每个人最喜欢的铅笔颜色吧?你是对的!但是,我们可以使用 -D 参数轻松地告诉 Java 关于我们的铅笔颜色(或任何我们想告诉它的东西)。

当启动 Java 运行时,可以使用 -D 选项在系统属性对象中定义一个值。它的参数必须包含名称、等号和值,这与属性文件中的解析方式相同(参见 Recipe 7.10)。在 java 命令和命令行上的类名之间可以有多个 -D 定义。在 Unix 或 Windows 命令行中,输入:

java -D"pencil_color=Deep Sea Green" environ.SysPropDemo

在 IDE 下运行时,在适当的对话框中(例如,在 Eclipse 的运行配置对话框中的程序参数下),将变量的名称和值放入其中。您还可以使用构建工具(Maven、Gradle 等)设置环境变量和系统属性。

SysPropDemo 程序中的代码用于提取一个或多个属性,您可以像这样运行它:

$ java environ.SysPropDemo os.arch
os.arch = x86

如果不带参数调用 SysPropDemo 程序,则输出与 Example 2-2 中的 jshell 片段相同的信息。

这提醒我——现在是提及依赖系统的代码的好时机。配方 2.3 讨论了依赖操作系统和发布依赖代码。

另请参阅

配方 7.10 列出了关于使用和命名自己的Properties文件的更多细节。java.util.Properties的 javadoc 页面列出了load()方法使用的确切规则,以及其他细节。

2.3 处理依赖于 Java 版本或操作系统的代码

问题

您需要编写适应底层操作系统的代码。

解决方案

您可以使用System.Properties来查找 Java 版本和操作系统,使用File类中的各种功能来查找一些平台相关的特性,以及java.awt.TaskBar来查看是否可以使用系统相关的任务栏或 Dock。

讨论

有些东西取决于您正在运行的 Java 版本。使用System.getProperty()参数为java.specification.version

或者,更普遍地说,您可能想测试特定类的存在与否。一种方法是使用Class.forName("class")(参见第十七章),如果无法加载类,则会抛出异常——这表明它在运行时库中不存在。示例 2-3 展示了这种情况下的代码,来自希望查找常见 Swing UI 组件是否可用的应用程序。标准类的 javadoc 报告了此类首次出现的 JDK 版本,标题为“Since”。如果没有此标题,通常意味着该类从 Java 开始就存在:

示例 2-3. main/src/main/java/starting/CheckForSwing.java
public class CheckForSwing {
    public static void main(String[] args) {
        try {
            Class.forName("javax.swing.JButton");
        } catch (ClassNotFoundException e) {
            String failure =
                "Sorry, but this version of MyApp needs \n" +
                "a Java Runtime with JFC/Swing components\n" +
                "having the final names (javax.swing.*)";
            // Better to make something appear in the GUI. Either a
            // JOptionPane, or: myPanel.add(new Label(failure));
            System.err.println(failure);
        }
        // No need to print anything here - the GUI should work...
    }
}

重要的是要区分在编译时和运行时测试此代码。在两种情况下,必须在包含您要测试的类的系统上编译它:JDK >= 1.1 和 Swing,分别。这些测试只是试图帮助那些试图运行您更新的应用程序的 Java 运行时用户。目标是为这些用户提供比运行时简单的“类未找到”错误更有意义的消息。还要注意,如果将此测试编写在依赖于您正在测试的代码的任何代码内部,该测试将变得无法到达。在应用程序的主流程中尽早放置测试,在构造任何 GUI 对象之前。否则,该代码只会浪费在更新的运行时上的空间,而在不包含 Swing 的 Java 系统上永远不会运行。显然,这只是一个非常早期的示例,但您可以使用相同的技术测试 Java 演变的任何阶段添加的任何运行时特性(请参阅附录 A 了解 Java 每个发布版本中添加的功能概述)。您还可以使用此技术确定是否已成功将所需的第三方库添加到您的 CLASSPATH 中。

此外,尽管 Java 旨在实现可移植性,但有些情况并非如此。其中包括文件名分隔符等变量。Unix 上的每个人都知道文件名分隔符是斜杠字符(/),反斜杠或反斜杠(\)是转义字符。回到 20 世纪 70 年代末,Microsoft 的一个小组实际上正在研究 Unix——他们的版本被称为 Xenix,后来被 SCO 接管——并且 DOS 上的开发人员看到并喜欢了 Unix 的文件系统模型。最早期的 MS-DOS 没有目录;它只有像 Digital Research CP/M(它本身是各种其他系统的克隆)那样的用户编号。因此,Microsoft 的开发人员着手克隆 Unix 的文件系统组织。不幸的是,MS-DOS 已经将斜杠字符用于用作选项分隔符,而 Unix 则使用破折号(-);并且PATH分隔符(:)也用作驱动器号分隔符,如C:或A:。因此,我们现在有了像在表 2-1 中所示的命令。

表 2-1. 目录列表命令

系统 目录列表命令 含义 示例 PATH 设置
Unix ls -R / /的递归列表,顶级目录 PATH=/bin:/usr/bin
DOS *dir/s * 目录及其子目录选项(即递归)的顶级目录(但仅限当前驱动器) PATH=C:\windows;D:\mybin

这对我们有何帮助?如果我们要在 Java 中生成文件名,我们可能需要知道是放置/还是\或其他字符。Java 有两种解决方案。首先,在移动到 Unix 和 Microsoft 系统之间时,至少是宽容的:可以使用/或\,¹,处理操作系统的代码会解决这个问题。其次,更普遍地,Java 以与平台无关的方式提供平台特定的信息。对于文件分隔符(以及PATH分隔符),java.io.File类提供了一些包含此信息的静态变量。由于File类管理着与平台相关的信息,因此将此信息锚定在这里是有意义的。变量在表 2-2 中显示。

表 2-2. 文件属性表

名称 类型 含义
separator static String 系统相关的文件名分隔符字符(例如,/或\)
separatorChar static char 系统相关的文件名分隔符字符(例如,/或\)
pathSeparator static String 系统相关的路径分隔符字符,以字符串形式表示以方便使用
pathSeparatorChar static char 系统相关的路径分隔符字符

文件名和路径分隔符通常是字符,但也以String形式提供以方便使用。

第二个更一般的机制是提到的System Properties对象,它在 Recipe 2.2 中提到。您可以使用它来确定您正在运行的操作系统。以下是一个简单列出系统属性的代码;在几个不同的实现上运行这个代码可能很有启发性:

public class SysPropDemo {
    public static void main(String[] argv) throws IOException {
        if (argv.length == 0)
            // tag::sysprops[]
            System.getProperties().list(System.out);
            // end::sysprops[]
        else {
            for (String s : argv) {
                System.out.println(s + " = " +
                    System.getProperty(s));
            }
        }
    }
}

例如,某些操作系统提供了称为空设备的机制,可用于丢弃输出(通常用于计时目的)。以下是请求系统属性os.name的代码,并使用它来编制可以用于丢弃数据的名称(如果给定平台没有已知的空设备,我们返回名称jnk,这意味着在这些平台上,我们偶尔会创建,嗯,垃圾文件;我在偶然发现时删除这些文件):

package com.darwinsys.lang;

import java.io.File;

/** Some things that are system-dependent.
 * All methods are static.
 * @author Ian Darwin
 */
public class SysDep {

    final static String UNIX_NULL_DEV = "/dev/null";
    final static String WINDOWS_NULL_DEV = "NUL:";
    final static String FAKE_NULL_DEV = "jnk";

    /** Return the name of the null device on platforms which support it,
     * or "jnk" (to create an obviously well-named temp file) otherwise.
     * @return The name to use for output.
     */
    public static String getDevNull() {

        if (new File(UNIX_NULL_DEV).exists()) {     ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
            return UNIX_NULL_DEV;
        }

        String sys = System.getProperty("os.name"); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        if (sys==null) {                            ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
            return FAKE_NULL_DEV;
        }
        if (sys.startsWith("Windows")) {            ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
            return WINDOWS_NULL_DEV;
        }
        return FAKE_NULL_DEV;                       ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
    }
}

1

如果/dev/null存在,请使用它。

2

如果没有,请询问System properties是否知道操作系统名称。

3

不行,所以放弃,返回jnk

4

我们知道这是 Microsoft Windows,所以使用NUL:

5

除非所有其他方法都失败,否则使用jnk

尽管 Java 的 Swing GUI 旨在具有可移植性,但苹果在 macOS 上的实现并不会自动为每个人都正确处理。例如,JMenuBar菜单容器默认出现在应用程序窗口的顶部。这在 Windows 和大多数 Unix 平台上是正常的,但 Mac 用户希望活动应用程序的菜单栏出现在屏幕顶部。为了启用正常行为,您必须在 Swing GUI 启动之前将System属性apple.laf.useScreenMenuBar设置为true。您可能还想设置其他一些属性,例如在菜单栏中显示应用程序的短名称(默认为主应用程序类的完整类名)。

书中源代码的示例,在src/main/java/gui/MacOsUiHints.java中可以找到。

设置这些属性可能没有意义,除非实际上是在 macOS 下运行。如何判断?苹果推荐的方法是检查系统属性mrj.runtime,如果是,则假定您正在使用 macOS:

boolean isMacOS = System.getProperty("mrj.version") != null;
if (isMacOS) {
  System.setProperty("apple.laf.useScreenMenuBar",  "true");
  System.setProperty("com.apple.mrj.application.apple.menu.about.name",
  "My Super App");
}

另一方面,在非 Mac 系统上这些属性可能无害,因此您可以跳过测试并无条件设置这两个属性。

最后,Mac 的 Dock 或大多数其他系统上的任务栏可以使用在 Java 9 中添加的java.awt.Taskbar类访问。这里没有讨论,但在main/gui子目录中有一个名为TaskbarDemo的示例。

2.4 使用扩展或其他打包的 API

问题

你有一个你想使用的类的 JAR 文件。

解决方案

只需将 JAR 文件添加到您的CLASSPATH中。

讨论

随着您构建更复杂的应用程序,您将需要使用越来越多的第三方库。您可以将它们添加到您的 CLASSPATH 中。

以前建议将这些 JAR 文件放入 Java 扩展机制目录,通常类似于 \jdk1.x\jre\lib\ext,而不是在 CLASSPATH 变量中列出每个 JAR 文件。但是,现在一般不再推荐这样做,最新的 JDK 中也不再支持。相反,您可能希望使用像 Maven(参见 Recipe 1.7)或 Gradle 这样的构建工具,以及集成开发环境(IDE),来自动添加 JAR 文件到您的 CLASSPATH 中。

我不喜欢使用扩展目录的一个原因是,它需要修改已安装的 JDK 或 JRE,这可能会导致维护问题,以及在安装新 JDK 或 JRE 时出现问题。

Java 9 引入了对 Java 的重大改变,即 Java 9 模块系统用于程序模块化,我们在 Recipe 2.5 中进行了讨论。

2.5 使用 Java 模块系统

问题

您正在使用 Java 9 或更高版本,并且需要处理模块机制。

解决方案

继续阅读。

讨论

Java 模块系统,以前称为项目 Jigsaw,旨在解决构建大型应用程序所需的许多小部分的问题。在某种程度上,像 Maven 和 Gradle 这样的工具已经解决了这个问题,但模块系统解决的问题与这些工具略有不同。Maven 或 Gradle 将找到依赖项,下载它们,在您的开发和测试运行时安装它们,并将它们打包成可运行的 JAR 文件。模块系统更关注于从一个应用程序代码块到另一个代码块的类的可见性,通常是由不同的开发人员提供,他们可能不了解或信任彼此。因此,这表明 Java 最初的访问修饰符集(如 publicprivateprotected 和默认可见性)对于构建大型应用程序是不够的。

接下来简要讨论使用 JPMS,即 Java 平台模块系统,将模块导入到您的应用程序中。在 第十五章 中介绍了创建自己的模块。如需更详细的介绍,您可以参考像 Java 9 Modularity: Patterns and Practices for Developing Maintainable Applications 这样的专著,作者是 Sander Mak 和 Paul Bakker(O’Reilly)。

Java 一直是大规模开发的语言。面向对象是其中的关键之一:类和对象组合方法,访问修饰符可以应用,使得公共和私有方法清晰分离。在开发大型应用程序时,仅具有类的单一扁平命名空间仍然不够。进入包:它们将类收集到其自己的命名空间内的逻辑组中。同样可以在包级别应用访问控制,以便某些类仅在包内可访问。模块是更高一级的下一步。模块将一些相关的包组合在一起,具有明确的名称,并且可以限制对某些包的访问,同时将其他包作为公共 API 暴露给不同的模块。

一开始要理解的一件事:JPMS 并不是你现有构建工具的替代品。无论你使用 Maven、Gradle、Ant,还是只是将所有需要的 JAR 文件倒入 lib 目录,你仍然需要这样做。同时,不要将 Maven 的模块与 JPMS 模块混淆;前者是将项目物理结构化为子项目,后者是 Java 平台(编译器、运行时)理解的内容。通常在使用 Java 模块时,每个 Java 模块将等同于一个单独的 Maven 模块。

当你处理一个小而自包含的程序时,你不需要担心模块。只需在编译时和运行时将所有必要的 JAR 文件放在你的CLASSPATH上,一切都会很好。可能。

在此过程中,你可能会看到类似以下的警告消息:

Illegal reflective access by com.foo.Bar
    (file:/Users/ian/.m2/repository/com/foo/1.3.1/foo-1.3.1.jar)
    to field java.util.Properties.defaults
Please consider reporting this to the maintainers of com.foo.Bar
Use --illegal-access=warn to enable warnings of further
 illegal reflective access operations
All illegal access operations will be denied in a future release

警告消息是由于 JPMS 在执行其作业时产生的,检查在模块内封装的包中是否访问了任何类型。随着所有公共 Java 库和正在开发的所有应用程序逐步模块化,此类消息将逐渐消失。

为什么仅“可能”一切都会很好?如果你使用了在过去几个版本中已被弃用的某些类,事情将无法编译。为此,你必须使相应的模块可用。在javasrc下的unsafe子目录(也是一个 Maven 模块)中,有一个名为LoadAverage的类。负载平均值是 Unix/Linux 系统的一个特性,通过报告等待运行的进程数,给出了系统负载或繁忙程度的粗略测量。几乎总是有更多的进程在运行,而 CPU 核心数却要少于它们,因此总有一些进程需要等待。更高的数字意味着一个更繁忙的系统,响应速度较慢。

Sun 不支持的Unsafe类具有一个方法来获取负载平均值,仅在支持它的系统上。代码必须使用反射 API(参见第十七章)来获取Unsafe对象;如果尝试直接实例化Unsafe,将会得到SecurityException(这在模块系统之前是这样)。一旦获取了实例并将其转换为Unsafe,就可以调用诸如loadAverage()(示例 2-4)之类的方法。

示例 2-4. unsafe/src/main/java/unsafe/LoadAverage.java(使用 Unsafe.java)
public class LoadAverage {
    public static void main(String[] args) throws Exception {
        Field f = Unsafe.class.getDeclaredField("theUnsafe");
        f.setAccessible(true);
        Unsafe unsafe = (Unsafe) f.get(null);
        int nelem = 3;
        double loadAvg[] = new double[nelem];
        unsafe.getLoadAverage(loadAvg, nelem);
        for (double d : loadAvg) {
            System.out.printf("%4.2f ", d);
        }
        System.out.println();
    }
}

此代码曾经编译通过,现在却产生了警告。如果我们正在使用 Java 模块,则必须修改我们的module-info.java文件,告诉编译器和 VM 我们需要使用半明显命名的模块jdk.unsupported

module javasrc.unsafe {
    requires jdk.unsupported;
	// others...
}

我们将在 Recipe 15.9 中详细介绍模块文件格式。

现在我们已经编写了代码并将模块文件放在了源文件夹的顶层,我们可以构建项目、运行程序,并将其输出与系统级工具uptime显示的负载平均值进行比较,例如,uptime。尽管我们仍然会收到“内部专有 API”警告,但它确实有效:

$ java -version
openjdk version "14-ea" 2020-03-17
OpenJDK Runtime Environment (build 14-ea+27-1339)
OpenJDK 64-Bit Server VM (build 14-ea+27-1339, mixed mode, sharing)
$ mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------< com.darwinsys:javasrc-unsafe >--------------------
[INFO] Building javasrc - Unsafe 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ javasrc-unsafe ---
[INFO] Deleting /Users/ian/workspace/javasrc/unsafe/target
[INFO]
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ javasrc-unsafe ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /Users/ian/workspace/javasrc/unsafe/src/main/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ javasrc-unsafe ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 2 source files to /Users/ian/workspace/javasrc/unsafe/target/classes
[WARNING] /Users/ian/workspace/javasrc/unsafe/src/main/java/unsafe/LoadAverage.java:[3,16] sun.misc.Unsafe is internal proprietary API and may be removed in a future release
[WARNING] /Users/ian/workspace/javasrc/unsafe/src/main/java/unsafe/LoadAverage.java:[12,27] sun.misc.Unsafe is internal proprietary API and may be removed in a future release
[WARNING] /Users/ian/workspace/javasrc/unsafe/src/main/java/unsafe/LoadAverage.java:[14,17] sun.misc.Unsafe is internal proprietary API and may be removed in a future release
[WARNING] /Users/ian/workspace/javasrc/unsafe/src/main/java/unsafe/LoadAverage.java:[14,34] sun.misc.Unsafe is internal proprietary API and may be removed in a future release
[INFO]
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ javasrc-unsafe ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /Users/ian/workspace/javasrc/unsafe/src/test/resources
[INFO]
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ javasrc-unsafe ---
[INFO] No sources to compile
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ javasrc-unsafe ---
[INFO] No tests to run.
[INFO]
[INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ javasrc-unsafe ---
[INFO] Building jar: /Users/ian/workspace/javasrc/unsafe/target/javasrc-unsafe-1.0.0-SNAPSHOT.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  4.668 s
[INFO] Finished at: 2020-01-05T14:53:55-05:00
[INFO] ------------------------------------------------------------------------
$
$ java -cp target/classes unsafe/LoadAverage
3.54 1.94 1.62
$ uptime
14:54  up 1 day, 21:50, 5 users, load averages: 3.54 1.94 1.62
$

幸运的是,它确实有效,并且与标准 Unix uptime命令给出相同的数字。至少在 Java 11 上有效。正如警告所示,它可能(即很可能)会在以后的版本中删除。

如果您正在构建一个更复杂的应用程序,您可能需要编写一个更完整的module-info.java文件。但在这个阶段,主要是需要的模块。标准 Java API 分为几个模块,您可以使用java命令列出它们:

$ java --list-modules
java.base
java.compiler
java.datatransfer
java.desktop
java.instrument
java.logging
java.management
java.management.rmi
java.naming
java.net.http
java.prefs
java.rmi
java.scripting
java.se
java.security.jgss
java.security.sasl
java.smartcardio
java.sql
java.sql.rowset
java.transaction.xa
java.xml
java.xml.crypto
... plus a bunch of JDK modules ...

其中,java.base始终可用且无需在模块文件中列出,java.desktop添加了用于图形的 AWT 和 Swing,而java.se则包含了 Java SDK 中以前的几乎所有公共 API。例如,如果我们的负载平均程序想要在 Swing 窗口中显示结果,就需要将其添加到模块文件中:

requires java.desktop;

当您的应用程序足够大,可以划分为多个层次或层次时,您可能希望使用 JPMS 描述这些模块。由于这个主题属于打包的范畴,它在 Recipe 15.9 中有描述。

¹ 在为在 Windows 上使用的字符串编译时,请记住在大多数地方(除了 MS-DOS 命令行)\ 是转义字符:String rootDir = "C:\\";.

第三章:字符串和其他东西

3.0 介绍

字符串是几乎任何编程任务中不可避免的一部分。我们用它们来向用户打印消息;用来引用磁盘上文件或其他外部媒体;以及用于人们的姓名、地址和所属关系。字符串的用途是多种多样的,几乎不计其数(实际上,如果你需要数字,我们会在第五章中介绍它们)。

如果你之前有 C 这样的编程语言的经验,你需要记住在 Java 中String是一个定义的类型(类)——也就是说,字符串是一个对象,因此有方法。它不是一个字符数组(尽管它包含一个),不应该被认为是一个数组。像fileName.endsWith(".gif")extension.equals(".gif")(以及相应的 ".gif".equals(extension))这样的操作是很常见的。¹

Java 老手应该注意,Java 11 和 12 添加了几个新的String方法,包括indent(int n)stripLeading()stripTrailing()Stream<T> lines()isBlank()transform()。其中大多数提供了明显的功能;最后一个允许将一个函数接口的实例(参见食谱 9.0)应用于字符串并返回该操作的结果。

尽管我们还没有讨论java.io包的细节(我们将在第十章中讨论),你需要能够读取一些程序所需的文本文件。即使你不熟悉java.io,你可能已经从读取文本文件的例子中看到,BufferedReader允许你读取数据块,并且这个类有一个非常方便的readLine()方法。

反过来说,System.out.println()通常用于将字符串或其他值打印到终端或标准输出。在这里通常使用字符串连接,像这样:

System.out.println("The answer is " + result);

一个关于字符串连接的警告是,如果你在追加一堆东西时,一个数字和一个字符在前面连接,由于 Java 的优先规则,它们会在连接之前被添加。因此,不要像我在这个假设的例子中那样做:

int result = ...;
System.out.println(result + '=' + " the answer.");

假设result是一个整数,那么result + '=' (result加上等号,这是一个数值类型的+字符)就是一个有效的*数值*表达式,其结果将是一个int类型的单一值。如果变量result的值是 42,并且考虑到 Unicode(或 ASCII)代码表中字符=`的值是 61,那么这两行片段将会打印出:

103 the answer.

错误的值和没有等号!更安全的方法包括使用括号、在等号周围加上双引号、使用StringBuilder(见 Recipe 3.2)或String.format()(见 Recipe 10.4)。当然,在这个简单的例子中,你可以将=移到字符串字面量的一部分,但选择这个例子是为了说明在char值上进行算术运算与字符串连接混淆的问题。我不会在这里向你展示如何对字符串数组进行排序;对对象集合进行排序的更一般概念将在 Recipe 7.11 中讨论。

Java 14 支持文本块,也称为多行文本字符串。这些文本块用一组三个双引号界定,开头的引号后面必须有一个换行符(这不会成为字符串的一部分;后续的换行符会):

String long = """
This is a long
text String."""

3.1 使用子字符串或标记化分解字符串

问题

你想要将字符串拆分成部分,可以通过索引位置或使用固定的令牌字符(例如,通过空格进行拆分以获取单词)。

解决方案

对于子字符串,使用String对象的substring()方法。对于标记化,构造一个围绕你的字符串的StringTokenizer,并调用它的方法hasMoreTokens()nextToken()

或者,使用正则表达式(见第四章)。

讨论

我们首先看子字符串,然后讨论标记化。

子字符串

substring()方法构造一个新的String对象,由原始字符串的某个位置包含的一系列字符组成,即你调用substring()的那个字符串。substring方法是重载的:两种形式都需要一个起始索引(始终是从零开始)。一种形式的参数返回从startIndex到末尾的内容。另一种形式采用一个结束索引(不是长度,如某些语言中那样),以便可以通过String方法indexOf()las⁠t​IndexOf()生成索引:

public class SubStringDemo {
    public static void main(String[] av) {
        String a = "Java is great.";
        System.out.println(a);
        String b = a.substring(5);    // b is the String "is great."
        System.out.println(b);
        String c = a.substring(5,7);// c is the String "is"
        System.out.println(c);
        String d = a.substring(5,a.length());// d is "is great."
        System.out.println(d);
    }
}

运行时,这将打印以下内容:

C:> java strings.SubStringDemo
Java is great.
is great.
is
is great.
C:>
警告

注意,结束索引是最后一个字符的索引加一!Java 在相当大程度上采用了这种半开区间(或包含起始,不包含结束)的策略;采用这种方法有很好的实际理由,一些其他语言也这样做。

标记化

最简单的方法是使用正则表达式。我们将在第四章讨论正则表达式,但现在,包含一个空格的字符串是一个有效的正则表达式,可以匹配空格字符,所以你可以最简单地这样将字符串拆分成单词:

for (String word : some_input_string.split(" ")) {
    System.out.println(word);
}

如果需要匹配多个空格,或空格和制表符,请使用字符串"\s+"

如果你想要拆分一个文件,可以尝试使用字符串","或使用几个第三方库来处理 CSV 文件。

另一种方法是使用StringTokenizerStringTokenizer方法实现了Iterator接口和设计模式(见 Recipe 7.6):

main/src/main/java/strings/StrTokDemo.java

StringTokenizer st = new StringTokenizer("Hello World of Java");

while (st.hasMoreTokens( ))
    System.out.println("Token: " + st.nextToken( ));

StringTokenizer还实现了Enumeration接口(参见 Recipe 7.6),但如果使用其中的方法,您需要将结果转换为String

一个StringTokenizer通常会在欧洲语言的单词边界处将String拆分为标记。有时您希望在其他字符处进行拆分。没问题。当您构造StringTokenizer时,除了传入要标记化的字符串外,还要传入第二个字符串,列出分隔字符,如下所示:

main/src/main/java/strings/StrTokDemo2.java

StringTokenizer st = new StringTokenizer("Hello, World|of|Java", ", |");

while (st.hasMoreElements( ))
    System.out.println("Token: " + st.nextElement( ));

它按每行一个的方式输出四个单词,没有标点符号。

等等,还有更多!如果您正在读取这样的行

FirstName|LastName|Company|PhoneNumber

而你亲爱的老阿姨贝贡尼亚在过去 38 年没有工作吗?她的Company字段很可能为空。³ 如果您仔细查看前面的代码示例,您会看到它有两个连续的分隔符(逗号和空格);但是如果您运行它,没有“额外”的标记——也就是说,StringTokenizer通常会丢弃相邻的连续分隔符。对于像电话列表这样需要保留空字段的情况,有好消息和坏消息。好消息是您可以做到:在构造StringTokenizer时添加第二个参数true,表示希望将分隔符视为标记。坏消息是现在您会看到这些分隔符作为标记,因此您必须自己进行算术计算。想看看吗?运行这个程序:

main/src/main/java/strings/StrTokDemo3.java

StringTokenizer st =
    new StringTokenizer("Hello, World|of|Java", ", |", true);

while (st.hasMoreElements( ))
    System.out.println("Token: " + st.nextElement( ));

您将获得此输出:

C:\>java strings.StrTokDemo3
Token: Hello
Token: ,
Token:
Token: World
Token: |
Token: of
Token: |
Token: Java
C:\>

理想情况下,StringTokenizer不应该这样行事,但在大多数情况下,它足够使用了。Example 3-1 处理并忽略连续的标记,将结果作为String数组返回。

Example 3-1. main/src/main/java/strings/StrTokDemo4.java (StringTokenizer)
public class StrTokDemo4 {
    public final static int MAXFIELDS = 5;
    public final static String DELIM = "|";

    /** Processes one String, returns it as an array of Strings */
    public static String[] process(String line) {
        String[] results = new String[MAXFIELDS];

        // Unless you ask StringTokenizer to give you the tokens,
        // it silently discards multiple null tokens.
        StringTokenizer st = new StringTokenizer(line, DELIM, true);

        int i = 0;
        // Stuff each token into the current slot in the array.
        while (st.hasMoreTokens()) {
            String s = st.nextToken();
            if (s.equals(DELIM)) {
                if (i++>=MAXFIELDS)
                    // This is messy: See StrTokDemo4b which uses
                    // a List to allow any number of fields.
                    throw new IllegalArgumentException("Input line " +
                        line + " has too many fields");
                continue;
            }
            results[i] = s;
        }
        return results;
    }

    public static void printResults(String input, String[] outputs) {
        System.out.println("Input: " + input);
        for (String s : outputs)
            System.out.println("Output " + s + " was: " + s);
    }

    public static void main(String[] a) {
        printResults("A|B|C|D", process("A|B|C|D"));
        printResults("A||C|D", process("A||C|D"));
        printResults("A|||D|E", process("A|||D|E"));
    }
}

当您运行此代码时,您会发现A始终在字段 1 中,B(如果存在)在字段 2 中,依此类推。换句话说,空字段被正确处理了:

Input: A|B|C|D
Output 0 was: A
Output 1 was: B
Output 2 was: C
Output 3 was: D
Output 4 was: null
Input: A||C|D
Output 0 was: A
Output 1 was: null
Output 2 was: C
Output 3 was: D
Output 4 was: null
Input: A|||D|E
Output 0 was: A
Output 1 was: null
Output 2 was: null
Output 3 was: D
Output 4 was: E

参见

许多StringTokenizer的出现可以用正则表达式替换(参见 Chapter 4),具有更大的灵活性。例如,要从一个String中提取所有数字,您可以使用以下代码:

Matcher tokenizer = Pattern.compile("\\d+").matcher(inputString);
while (tokenizer.find( )) {
        String courseString = tokenizer.group(0);
        int courseNumber = Integer.parseInt(courseString);
        ...

这允许用户输入比您可以轻松处理的StringTokenizer更灵活。假设数字表示某个教育机构的课程号,输入“471,472,570”或“课程 471 和 472, 570”或只是“471 472 570”应该都给出相同的结果。

3.2 使用 StringBuilder 组合字符串

问题

你需要把一些String片段(再)组合在一起。

解决方案

使用字符串连接:+运算符。编译器会为您隐式构造一个StringBuilder,并使用其append()方法(除非所有字符串部分在编译时都已知)。

更好的方法是自己构建和使用StringBuilder

讨论

StringBuilder类的对象基本上表示一个字符集合。它类似于一个String对象。⁴ 然而,正如前面提到的,String是不可变的;而StringBuilder是可变的,专门用于构建String。通常您会构造一个StringBuilder,调用需要的方法以便按照您想要的方式获取字符序列,然后调用toString()方法生成表示相同字符序列的String,以便在大多数处理String的 Java API 中使用。

StringBuffer是历史悠久的——它从时间的开始就存在。它的一些方法是同步的(参见 Recipe 16.5),这在单线程环境中会产生不必要的开销。在 Java 5 中,这个类被分成了StringBuffer(同步的)和StringBuilder(非同步的);因此,对于单线程使用来说,它更快速且更可取。另一个新类AbstractStringBuilder是这两者的父类。在接下来的讨论中,我会使用“StringBuilder类”来指代这三者,因为它们大部分具有相同的方法。

书中的示例代码提供了一个StringBuilderDemo和一个StringBufferDemo。除了StringBuilder不是线程安全这一事实外,这些 API 类完全相同,可以互换使用,因此我的两个演示程序几乎相同,只是每个程序使用了适当的构建器类。

StringBuilder类提供了各种方法来插入、替换和修改给定的StringBuilder。方便的是,append()方法返回对StringBuilder本身的引用,因此像.append(…).append(…)这样的堆叠语句是相当常见的。这种编码风格被称为流式 API,因为它读起来流畅,像母语为人类语言的人的散文。例如,在toString()方法中甚至可能看到这种编码风格。Example 3-2 展示了三种字符串连接的方式。

示例 3-2. main/src/main/java/strings/StringBuilderDemo.java
public class StringBuilderDemo {

    public static void main(String[] argv) {

        String s1 = "Hello" + ", " + "World";
        System.out.println(s1);

        // Build a StringBuilder, and append some things to it.
        StringBuilder sb2 = new StringBuilder();
        sb2.append("Hello");
        sb2.append(',');
        sb2.append(' ');
        sb2.append("World");

        // Get the StringBuilder's value as a String, and print it.
        String s2 = sb2.toString();
        System.out.println(s2);

        // Now do the above all over again, but in a more
        // concise (and typical "real-world" Java) fashion.

        System.out.println(
          new StringBuilder()
            .append("Hello")
            .append(',')
            .append(' ')
            .append("World"));
    }
}

实际上,所有修改StringBuilder内容超过一个字符的方法(即append()delete()deleteCharAt()insert()replace()reverse())都会返回对构建器对象的引用,以支持这种流畅的 API 编程风格。

再举一个使用StringBuilder的例子,考虑将项目列表转换为逗号分隔的列表的需求,同时避免在列表的最后一个元素后面得到额外的逗号。这可以通过StringBuilder来实现,尽管在 Java 8+中有一个静态的String方法来完成相同的工作。这些代码显示在 Example 3-3 中。

Example 3-3. main/src/main/java/strings/StringBuilderCommaList.java
        System.out.println(
            "Split using String.split; joined using 1.8 String join");
        System.out.println(String.join(", ", SAMPLE_STRING.split(" ")));

        System.out.println(
            "Split using String.split; joined using StringBuilder");
        StringBuilder sb1 = new StringBuilder();
        for (String word : SAMPLE_STRING.split(" ")) {
            if (sb1.length() > 0) {
                sb1.append(", ");
            }
            sb1.append(word);
        }
        System.out.println(sb1);

        System.out.println(
            "Split using StringTokenizer; joined using StringBuilder");
        StringTokenizer st = new StringTokenizer(SAMPLE_STRING);
        StringBuilder sb2 = new StringBuilder();
        while (st.hasMoreElements()) {
            sb2.append(st.nextToken());
            if (st.hasMoreElements()) {
                sb2.append(", ");
            }
        }
        System.out.println(sb2);

第一种方法显然是最紧凑的;静态的String.join()可以轻松完成这个任务。接下来的方法使用了StringBuilder.length()方法,因此只有在你从一个空的StringBuilder开始时才能正确工作。第二种方法依赖于在每个元素上多次调用枚举类型中的信息方法hasMoreElements()(或者在Iterator中调用hasNext(),如 Recipe 7.6 所讨论的那样)。一种替代方法,特别是当你不是从一个空的构建器开始时,可以使用一个boolean标志变量来跟踪你是否位于列表的开头。

3.3 逐个字符处理字符串

问题

您希望逐个字符处理字符串的内容。

解决方案

使用for循环和StringcharAt()codePointAt()方法。或使用“for each”循环和StringtoCharArray方法。

讨论

字符串的charAt()方法通过索引号(从零开始)从String对象中检索给定字符。由于 Unicode 已经扩展到超过 16 位,不是所有的 Unicode 字符都能容纳在 Java 的char变量中。因此有一个类似的codePointAt()方法,其返回类型是int。要处理String中的所有字符,可以使用一个从零到String.length()-1for循环。这里我们处理String中的所有字符:

main/src/main/java/strings/strings/StrCharAt.java

public class StrCharAt {
    public static void main(String[] av) {
        String a = "A quick bronze fox";
        for (int i=0; i < a.length(); i++) { // no forEach, need the index
            String message = String.format(
                "charAt is '%c', codePointAt is %3d, casted it's '%c'",
                     a.charAt(i),
                     a.codePointAt(i),
                     (char)a.codePointAt(i));
            System.out.println(message);
        }
    }
}

鉴于“for each”循环已经存在很久,你可能原谅你期望能够像这样写一些东西for (char ch : myString) {…}。不幸的是,这并不起作用。但是你可以使用myString.toCharArray()如下所示:

public class ForEachChar {
    public static void main(String[] args) {
        String mesg = "Hello world";

        // Does not compile, Strings are not iterable
        // for (char ch : mesg) {
        //        System.out.println(ch);
        // }

        System.out.println("Using toCharArray:");
        for (char ch : mesg.toCharArray()) {
            System.out.println(ch);
        }

        System.out.println("Using Streams:");
        mesg.chars().forEach(c -> System.out.println((char)c));
    }
}

校验和是表示和确认文件内容的数字量。如果你将文件的校验和与内容分开传输,接收方可以对文件进行校验——假设算法是已知的——并验证文件是否完整接收。Example 3-4 展示了一种最简单的校验和计算方法,只需将每个字符的数值相加。注意,在文件上,它不包括换行符的值;为了修正这一点,检索System​.getProp⁠erty("line.separator");并将其字符值添加到每行的末尾。或者放弃行模式,逐个字符读取文件。

Example 3-4. main/src/main/java/strings/CheckSum.java
    /** CheckSum one text file, given an open BufferedReader.
 * Checksum does not include line endings, so will give the
 * same value for given text on any platform. Do not use
 * on binary files!
 */
    public static int process(BufferedReader is) {
        int sum = 0;
        try {
            String inputLine;

            while ((inputLine = is.readLine()) != null) {
                for (char c : inputLine.toCharArray()) {
                    sum += c;
                }
            }
        } catch (IOException e) {
            throw new RuntimeException("IOException: " + e);
        }
        return sum;
    }

3.4 对齐、缩进和取消缩进字符串

问题

你想将字符串左对齐、右对齐或居中。

解决方案

自己做数学运算,使用 substring(参见 Recipe 3.1) 和 StringBuilder(参见 Recipe 3.2)。或者,使用我的 StringAlign 类,该类基于 java.text.Format 类。对于左对齐或右对齐,使用 String.format()

讨论

文本居中和对齐经常会出现。假设你想打印一个简单的报告,并希望页面数字居中显示。标准 API 中似乎没有任何可以完全帮助你完成工作的东西。但我写了一个名为 String​A⁠lign 的类可以做到。下面是你可能如何使用它的方法:

public class StringAlignSimple {

    public static void main(String[] args) {
        // Construct a "formatter" to center strings.
        StringAlign formatter = new StringAlign(70, StringAlign.Justify.CENTER);
        // Try it out, for page "i"
        System.out.println(formatter.format("- i -"));
        // Try it out, for page 4\. Since this formatter is
        // optimized for Strings, not specifically for page numbers,
        // we have to convert the number to a String
        System.out.println(formatter.format(Integer.toString(4)));
    }
}

如果你编译并运行这个类,它会打印出两个演示行号,并使它们居中显示,如下所示:

> javac -d . StringAlignSimple.java
> java strings.StringAlignSimple
                                - i -
                                  4
>

示例 3-5 是 StringAlign 类的代码。注意,该类扩展了 java.text 包中的 Format 类。有一系列至少有一个名为 format() 的方法的 Format 类。因此,它与许多其他格式化程序(如 DateFormatNumberFormat)一起,我们将在接下来的章节中看到。

示例 3-5. main/src/main/java/strings/StringAlign.java
public class StringAlign extends Format {

    private static final long serialVersionUID = 1L;

    public enum Justify {
        /* Constant for left justification. */
        LEFT,
        /* Constant for centering. */
        CENTER,
        /** Constant for right-justified Strings. */
        RIGHT,
    }

    /** Current justification */
    private Justify just;
    /** Current max length */
    private int maxChars;

    /** Construct a StringAlign formatter; length and alignment are
 * passed to the Constructor instead of each format() call as the
 * expected common use is in repetitive formatting e.g., page numbers.
 * @param maxChars - the maximum length of the output
 * @param just - one of the enum values LEFT, CENTER or RIGHT
 */
    public StringAlign(int maxChars, Justify just) {
        switch(just) {
        case LEFT:
        case CENTER:
        case RIGHT:
            this.just = just;
            break;
        default:
            throw new IllegalArgumentException("invalid justification arg.");
        }
        if (maxChars < 0) {
            throw new IllegalArgumentException("maxChars must be positive.");
        }
        this.maxChars = maxChars;
    }

    /** Format a String.
 * @param input - the string to be aligned.
 * @parm where - the StringBuilder to append it to.
 * @param ignore - a FieldPosition (may be null, not used but
 * specified by the general contract of Format).
 */
    @Override
    public StringBuffer format(
        Object input, StringBuffer where, FieldPosition ignore)  {

        String s = input.toString();
        String wanted = s.substring(0, Math.min(s.length(), maxChars));

        // Get the spaces in the right place.
        switch (just) {
            case RIGHT:
                pad(where, maxChars - wanted.length());
                where.append(wanted);
                break;
            case CENTER:
                int toAdd = maxChars - wanted.length();
                pad(where, toAdd/2);
                where.append(wanted);
                pad(where, toAdd - toAdd/2);
                break;
            case LEFT:
                where.append(wanted);
                pad(where, maxChars - wanted.length());
                break;
            }
        return where;
    }

    protected final void pad(StringBuffer to, int howMany) {
        for (int i=0; i<howMany; i++)
            to.append(' ');
    }

    /** Convenience Routine */
    String format(String s) {
        return format(s, new StringBuffer(), null).toString();
    }

    /** ParseObject is required, but not useful here. */
    public Object parseObject (String source, ParsePosition pos)  {
        return source;
    }
}

Java 12 引入了一个名为 public String indent(int n) 的新方法,该方法将 n 个空格添加到字符串前面,字符串被视为带有换行符的行序列。这与 Java 11 的 Stream<String> lines() 方法配合使用效果很好。例如,对于一系列已经存储在单个字符串中的行需要相同的缩进的情况(Streams 和 “::” 符号的使用在 Recipe 9.0 中有介绍):

jshell> "abc\ndef".indent(30).lines().forEach(System.out::println);
                              abc
                              def

jshell> "abc\ndef".indent(30).indent(-10).lines().forEach(System.out::println);
                    abc
                    def

jshell>

参见

数字列的对齐考虑在 第 5 章 中。

3.5 Unicode 字符和字符串之间的转换

问题

你想在 Unicode 字符和 String 之间转换。

解决方案

使用 Java charString 数据类型处理字符;这些类型本身支持 Unicode。如果需要,将字符打印为整数以显示它们的 原始 值。

讨论

Unicode 是一个国际标准,旨在表示人们在各种语言中使用的所有已知字符。尽管原始的 ASCII 字符集是其子集,但 Unicode 是庞大的。在 Java 创建时,Unicode 是一个 16 位字符集,因此将 Java 的 char 值设为 16 位宽度似乎是自然的选择,多年来,char 可以容纳任何 Unicode 字符。然而,随着时间的推移,Unicode 不断增长,现在包括超过一百万个代码点或字符,远远超过了 16 位可以表示的 65,525 个字符。[⁵] 并非所有可能的 16 位值都被定义为 UCS-2 中的字符,UCS-2 是 Java 中最初使用的 16 位版本的 Unicode。一些被保留为转义字符,允许将多字符长度映射到不常见的字符。幸运的是,有一个中间标准,称为 UTF-16(16 位 Unicode 转换格式)。正如 String 类文档所述:

String 表示 UTF-16 格式中的字符串,其中补充字符代理对表示(有关更多信息,请参见Character 类中 Unicode 字符表示部分)。索引值指的是字符代码单元,因此补充字符在 String 中使用两个位置。

String 类提供了处理 Unicode 代码点(即字符)的方法,除了处理 Unicode 代码单元(即 char values)的方法。

StringcharAt() 方法返回指定偏移量处字符的 char 值。StringBuilder append() 方法有一种形式接受一个 char。因为 char 是一个整数类型,你甚至可以对 char 进行算术运算,尽管这不像在 C 中那样频繁需要。也不常推荐,因为 Character 类提供了在诸如 C 语言中通常使用这些操作的方法。这里是一个使用 char 算术控制循环并将字符附加到 StringBuilder 中的程序(见 Recipe 3.2):

        // UnicodeChars.java
        StringBuilder b = new StringBuilder();
        for (char c = 'a'; c<'d'; c++) {
            b.append(c);
        }
        b.append('\u00a5');    // Japanese Yen symbol
        b.append('\u01FC');    // Roman AE with acute accent
        b.append('\u0391');    // GREEK Capital Alpha
        b.append('\u03A9');    // GREEK Capital Omega

        for (int i=0; i<b.length(); i++) {
            System.out.printf(
                "Character #%d (%04x) is %c%n",
                i, (int)b.charAt(i), b.charAt(i));
        }
        System.out.println("Accumulated characters are " + b);

当你运行它时,预期的结果将为 ASCII 字符打印出来。在 Unix 和 Mac 系统中,默认字体不包括所有附加字符,因此它们要么被省略,要么映射到不规则字符:

$ java -cp target/classes strings.UnicodeChars
Character #0 (0061) is a
Character #1 (0062) is b
Character #2 (0063) is c
Character #3 (00a5) is ¥
Character #4 (01fc) is Ǽ
Character #5 (0391) is Α
Character #6 (03a9) is Ω
Accumulated characters are abc¥ǼΑΩ
$

Windows 系统也不包含大多数这些字符,但至少它打印出它知道缺失的字符作为问号(Windows 系统字体比各种 Unix 系统更同质化,因此更容易知道哪些不起作用)。另一方面,它尝试将日元符号打印为带有波浪号的 N:

Character #0 is a
Character #1 is b
Character #2 is c
Character #3 is ¥
Character #4 is ?
Character #5 is ?
Character #6 is ?
Accumulated characters are abc¥___

“_” 字符是不可打印的字符。

另请参见

本书在线源代码中的Unicode程序显示 Unicode 字符集的任何 256 字符部分。您可以从Unicode Consortium下载列出 Unicode 字符集中每个字符的文档。

3.6 按单词或按字符反转字符串

问题

您希望逐字符或逐单词反转字符串。

解决方案

您可以轻松地按字符反转字符串,使用StringBuilder。有几种方法可以按单词反转字符串。一种自然的方法是使用StringTokenizer和一个堆栈。Stack是一个类(定义在java.util中;参见 Recipe 7.16),实现了一个易于使用的后进先出(LIFO)对象堆栈。

讨论

要反转字符串中的字符,请使用StringBuilder reverse()方法:

main/src/main/java/strings/StringRevChar.java

String sh = "FCGDAEB";
System.out.println(sh + " -> " + new StringBuilder(sh).reverse());

这个例子中的字母列出了西方音乐调号中升调的顺序;反过来,它列出了降调的顺序。当然,您也可以自己反转字符,使用逐字符模式(参见 Recipe 3.3)。

一种流行的记忆法或助记符,帮助音乐学生记住升调和降调的顺序,是用每个升调的一个单词而不是一个字母。让我们逐个单词地反转这一点。 示例 3-6 将每个添加到一个Stack(参见 Recipe 7.16),然后以 LIFO 顺序处理整个批次,以颠倒顺序。

示例 3-6. main/src/main/java/strings/StringReverse.java
        String s = "Father Charles Goes Down And Ends Battle";

        // Put it in the stack frontwards
        Stack<String> myStack = new Stack<>();
        StringTokenizer st = new StringTokenizer(s);
        while (st.hasMoreTokens()) {
            myStack.push(st.nextToken());
        }

        // Print the stack backwards
        System.out.print('"' + s + '"' + " backwards by word is:\n\t\"");
        while (!myStack.empty()) {
            System.out.print(myStack.pop());
            System.out.print(' ');    // inter-word spacing
        }
        System.out.println('"');

3.7 扩展和压缩制表符

问题

您需要在文件中将空格字符转换为制表符字符,或者反之。您可能希望将空格替换为制表符以节省磁盘空间,或者反之以处理无法处理制表符的设备或程序。

解决方案

使用我的Tabs类或其子类EnTab

讨论

因为处理带制表符文本或数据的程序期望制表位于固定位置,您不能使用典型的文本编辑器替换制表符为空格或反之。 示例 3-7 是EnTab的列表,包含一个示例主程序。该程序逐行工作。对于每行的每个字符,如果字符是空格,我们看看是否可以与之前的空格合并以输出单个制表符字符。该程序依赖于Tabs类,我们稍后会讲到。Tabs类用于确定哪些列位置代表制表位,哪些不是。

示例 3-7. main/src/main/java/strings/Entab.java
public class EnTab {

    private static Logger logger = Logger.getLogger(EnTab.class.getSimpleName());

    /** The Tabs (tab logic handler) */
    protected Tabs tabs;

    /**
 * Delegate tab spacing information to tabs.
 */
    public int getTabSpacing() {
        return tabs.getTabSpacing();
    }

    /**
 * Main program: just create an EnTab object, and pass the standard input
 * or the named file(s) through it.
 */
    public static void main(String[] argv) throws IOException {
        EnTab et = new EnTab(8);
        if (argv.length == 0) // do standard input
            et.entab(
                new BufferedReader(new InputStreamReader(System.in)),
                System.out);
        else
            for (String fileName : argv) { // do each file
                et.entab(
                    new BufferedReader(new FileReader(fileName)),
                    System.out);
            }
    }

    /**
 * Constructor: just save the tab values.
 * @param n The number of spaces each tab is to replace.
 */
    public EnTab(int n) {
        tabs = new Tabs(n);
    }

    public EnTab() {
        tabs = new Tabs();
    }

    /**
 * entab: process one file, replacing blanks with tabs.
 * @param is A BufferedReader opened to the file to be read.
 * @param out a PrintWriter to send the output to.
 */
    public void entab(BufferedReader is, PrintWriter out) throws IOException {

        // main loop: process entire file one line at a time.
        is.lines().forEach(line -> {
            out.println(entabLine(line));
        });
    }

    /**
 * entab: process one file, replacing blanks with tabs.
 *
 * @param is A BufferedReader opened to the file to be read.
 * @param out A PrintStream to write the output to.
 */
    public void entab(BufferedReader is, PrintStream out) throws IOException {
        entab(is, new PrintWriter(out));
    }

    /**
 * entabLine: process one line, replacing blanks with tabs.
 * @param line the string to be processed
 */
    public String entabLine(String line) {
        int N = line.length(), outCol = 0;
        StringBuilder sb = new StringBuilder();
        char ch;
        int consumedSpaces = 0;

        for (int inCol = 0; inCol < N; inCol++) { // Cannot use foreach here
            ch = line.charAt(inCol);
            // If we get a space, consume it, don't output it.
            // If this takes us to a tab stop, output a tab character.
            if (ch == ' ') {
                logger.info("Got space at " + inCol);
                if (tabs.isTabStop(inCol)) {
                    logger.info("Got a Tab Stop " + inCol);
                    sb.append('\t');
                    outCol += consumedSpaces;
                    consumedSpaces = 0;
                } else {
                    consumedSpaces++;
                }
                continue;
            }

            // We're at a non-space; if we're just past a tab stop, we need
            // to put the "leftover" spaces back out, since we consumed
            // them above.
            while (inCol-1 > outCol) {
                logger.info("Padding space at " + inCol);
                sb.append(' ');
                outCol++;
            }

            // Now we have a plain character to output.
            sb.append(ch);
            outCol++;

        }
        // If line ended with trailing (or only!) spaces, preserve them.
        for (int i = 0; i < consumedSpaces; i++) {
            logger.info("Padding space at end # " + i);
            sb.append(' ');
        }
        return sb.toString();
    }
}

这段代码是模仿 Kernighan 和 Plauger 经典著作《软件工具》中的一个程序编写的。虽然他们的版本是用一种叫做 RatFor(Rational Fortran)的语言编写的,但我的版本经过多次翻译。他们的版本实际上是逐个字符处理的,我很长一段时间都试图保留这种整体结构。最终,我将其重写为逐行处理的程序。

反方向进行操作——将制表符插入而不是删除——的程序是DeTab类,示例见 Example 3-8;这里只展示了核心方法。

示例 3-8. main/src/main/java/strings/DeTab.java
public class DeTab {
    Tabs ts;

    public static void main(String[] argv) throws IOException {
        DeTab dt = new DeTab(8);
        dt.detab(new BufferedReader(new InputStreamReader(System.in)),
                new PrintWriter(System.out));
    }

    public DeTab(int n) {
        ts = new Tabs(n);
    }
    public DeTab() {
        ts = new Tabs();
    }

    /** detab one file (replace tabs with spaces)
 * @param is - the file to be processed
 * @param out - the updated file
 */
    public void detab(BufferedReader is, PrintWriter out) throws IOException {
        is.lines().forEach(line -> {
            out.println(detabLine(line));
        });
    }

    /** detab one line (replace tabs with spaces)
 * @param line - the line to be processed
 * @return the updated line
 */
    public String detabLine(String line) {
        char c;
        int col;
        StringBuilder sb = new StringBuilder();
        col = 0;
        for (int i = 0; i < line.length(); i++) {
            // Either ordinary character or tab.
            if ((c = line.charAt(i)) != '\t') {
                sb.append(c); // Ordinary
                ++col;
                continue;
            }
            do { // Tab, expand it, must put >=1 space
                sb.append(' ');
            } while (!ts.isTabStop(++col));
        }
        return sb.toString();
    }
}

Tabs类提供了两个方法:settabpos()istabstop()。Example 3-9 是Tabs类的源代码。

示例 3-9. main/src/main/java/strings/Tabs.java
public class Tabs {
    /** tabs every so often */
    public final static int DEFTABSPACE =   8;
    /** the current tab stop setting. */
    protected int tabSpace = DEFTABSPACE;
    /** the longest line that we initially set tabs for */
    public final static int MAXLINE  = 255;

    /** Construct a Tabs object with a given tab stop settings */
    public Tabs(int n) {
        if (n <= 0) {
            n = 1;
        }
        tabSpace = n;
    }

    /** Construct a Tabs object with a default tab stop settings */
    public Tabs() {
        this(DEFTABSPACE);
    }

    /**
 * @return Returns the tabSpace.
 */
    public int getTabSpacing() {
        return tabSpace;
    }

    /** isTabStop - returns true if given column is a tab stop.
 * @param col - the current column number
 */
    public boolean isTabStop(int col) {
        if (col <= 0)
            return false;
        return (col+1) % tabSpace == 0;
    }
}

3.8 控制大小写

问题

您需要将字符串转换为大写或小写,或者在比较字符串时忽略大小写。

解决方案

String类有多个用于处理特定情况下文档的方法。toUpperCase()toLowerCase()分别返回一个新的字符串副本,按照其名称进行转换。每个方法都可以使用无参数或使用指定转换规则的Locale参数调用;这是因为国际化的需要。Java 的 API 提供了重要的国际化和本地化功能,详见“Ian's Basic Steps: Internationalization and Localization”。而equals()方法告诉您另一个字符串是否完全相同,equalsIgnoreCase()告诉您所有字符是否相同,而不考虑大小写。在这里,您不能指定替代区域设置;系统使用默认区域设置:

        String name = "Java Cookbook";
        System.out.println("Normal:\t" + name);
        System.out.println("Upper:\t" + name.toUpperCase());
        System.out.println("Lower:\t" + name.toLowerCase());
        String javaName = "java cookBook"; // If it were Java identifiers :-)
        if (!name.equals(javaName))
            System.err.println("equals() correctly reports false");
        else
            System.err.println("equals() incorrectly reports true");
        if (name.equalsIgnoreCase(javaName))
            System.err.println("equalsIgnoreCase() correctly reports true");
        else
            System.err.println("equalsIgnoreCase() incorrectly reports false");

如果您运行此代码,它将打印第一个名称的大写和小写版本,然后报告两种方法按预期工作:

C:\javasrc\strings>java strings.Case
Normal: Java Cookbook
Upper:  JAVA COOKBOOK
Lower:  java cookbook
equals( ) correctly reports false
equalsIgnoreCase( ) correctly reports true

参见

正则表达式使得在字符串搜索中忽略大小写更加简单(见 Chapter 4)。

3.9 输入不可打印字符

问题

你需要将不可打印字符放入字符串中。

解决方案

使用反斜杠字符和 Java 字符串转义序列之一。

讨论

Java 字符串转义列在 Table 3-1 中。

表格 3-1. 字符串转义

获取 使用 注释
制表符 \t
换行符(Unix 换行符) \n 调用System.getProperty("line.separator")将给出平台的行尾。
回车符 \r
换页符 \f
退格符 \b
单引号 \'
双引号 \"
Unicode 字符 \u NNNN 四个十六进制数字(无\x,类似于 C/C++)。参见http://www.unicode.org获取代码。
八进制(!)字符 ++NNN 现在谁还使用八进制(基数 8)?
反斜杠 \\

这里是一个代码示例,展示了其中大部分功能:

public class StringEscapes {
    public static void main(String[] argv) {
        System.out.println("Java Strings in action:");
        // System.out.println("An alarm or alert: \a");    // not supported
        System.out.println("An alarm entered in Octal: \007");
        System.out.println("A tab key: \t(what comes after)");
        System.out.println("A newline: \n(what comes after)");
        System.out.println("A UniCode character: \u0207");
        System.out.println("A backslash character: \\");
    }
}

如果你有很多非 ASCII 字符需要输入,你可能希望考虑使用 Java 的输入方法,这在在线文档中简要讨论过。

3.10 剥离字符串末尾的空白

问题

你需要处理用户可能输入的额外前导或尾随空格的字符串。

解决方案

使用 String 类的 strip()trim() 方法。

讨论

String 类中有四种方法可以实现这个功能:

strip()

返回一个去除了所有前导和尾随空白的字符串

stripLeading()

返回一个去除了所有前导空白的字符串

stripTrailing()

返回去除了所有尾随空白的字符串

String trim()

返回去除了所有前导和尾随空格的字符串

对于 strip() 方法,空白由 Character.isSpace() 定义。对于 trim() 方法,空格包括任何数值小于或等于 32 的字符,或 U+0020(空格字符)。

示例 3-10 使用 trim() 来剥离 Java 源代码行中任意数量的前导空格和/或制表符,以便查找字符 //+//-。这些字符串是我之前用来标记本书中要包含在印刷本中的程序部分的特殊 Java 注释。

示例 3-10. main/src/main/java/strings/GetMark.java(剥离和比较字符串)
public class GetMark {
    /** the default starting mark */
    public final String START_MARK = "//+";
    /** the default ending mark */
    public final String END_MARK = "//-";
    /** Set this to TRUE for running in "exclude" mode (e.g., for
 * building exercises from solutions) and to FALSE for running
 * in "extract" mode (e.g., writing a book and omitting the
 * imports and "public class" stuff).
 */
    public final static boolean START = true;
    /** True if we are currently inside marks */
    protected boolean printing = START;
    /** True if you want line numbers */
    protected final boolean number = false;

    /** Get Marked parts of one file, given an open LineNumberReader.
 * This is the main operation of this class, and can be used
 * inside other programs or from the main() wrapper.
 */
    public void process(String fileName,
        LineNumberReader is,
        PrintStream out) {
        int nLines = 0;
        try {
            String inputLine;

            while ((inputLine = is.readLine()) != null) {
                if (inputLine.trim().equals(START_MARK)) {
                    if (printing)
                        // These go to stderr, so you can redirect the output
                        System.err.println("ERROR: START INSIDE START, " +
                            fileName + ':' + is.getLineNumber());
                    printing = true;
                } else if (inputLine.trim().equals(END_MARK)) {
                    if (!printing)
                        System.err.println("ERROR: STOP WHILE STOPPED, " +
                            fileName + ':' + is.getLineNumber());
                    printing = false;
                } else if (printing) {
                    if (number) {
                        out.print(nLines);
                        out.print(": ");
                    }
                    out.println(inputLine);
                    ++nLines;
                }
            }
            is.close();
            out.flush(); // Must not close - caller may still need it.
            if (nLines == 0)
                System.err.println("ERROR: No marks in " + fileName +
                    "; no output generated!");
        } catch (IOException e) {
            System.out.println("IOException: " + e);
        }
    }

3.11 使用 I18N 资源创建消息

问题

你希望你的程序接受敏感性培训,以便能够在国际上良好地沟通。

解决方案

你的程序必须通过国际化软件获取所有控件和消息字符串。下面是方法:

  1. 获取一个 ResourceBundle

    ResourceBundle rb = ResourceBundle.getBundle("Menus");
    

    我会在食谱 3.13 中讨论 ResourceBundle,但简要来说,ResourceBundle 表示一组名称-值对(资源)。名称是您分配给每个 GUI 控件或其他用户界面文本的名称,而值是分配给每个控件的文本在给定语言中的文本。

  2. 使用这个 ResourceBundle 来获取每个控件名称的本地化版本。

    旧方法:

    String label = "Exit";
    // Create the control, e.g., new JButton(label);
    

    新方法:

    try { label = rb.getString("exit.label"); }
    catch (MissingResourceException e) { label="Exit"; } // fallback
    // Create the control, e.g., new JButton(label);
    

对于一个控件来说,这可能是相当多的代码,但是你可以编写一个方便的例程来简化它,就像这样:

JButton exitButton = I18NUtil.getButton("exit.label", "Exit");

文件 I18NUtil.java 包含在书的代码分发中。

虽然示例是一个 Swing JButton,但同样的方法也适用于其他 UI,比如 Web 层。例如,在 JSF 中,你可以将字符串放在一个名为 resources.properties 的属性文件中,并将其存储在 src/main/resources 中。你可以在 faces-config.xml 中加载这个文件:

  <application>
    <locale-config>
        <default-locale>en</default-locale>
        <supported-locale>en</supported-locale>
        <supported-locale>es</supported-locale>
        <supported-locale>fr</supported-locale>
    </locale-config>
    <resource-bundle>
        <base-name>resources</base-name>
        <var>msg</var>
    </resource-bundle>
  </application>

然后在每个需要这些字符串的 Web 页面中,使用表达式中的 msg 变量引用资源:

// In signup.xhtml:
<h:outputText value="#{msg.prompt_firstname}"/>
<h:inputText required="true" id="firstName" value="#{person.firstName}" />

运行时会发生什么?

使用默认区域设置,因为我们没有指定任何一个。默认区域设置是依赖于平台的:

Unix/POSIX

LANG 环境变量(每个用户)

Windows

控制面板→区域设置

macOS

系统首选项→语言与文本

其他

查看平台文档

ResourceBundle.getBundle()会查找具有指定资源包名称的文件(在前面的示例中为Menus),加上下划线和语言环境名称(如果设置了非默认语言环境),再加上另一个下划线和语言环境变体(如果设置了变体),最后加上扩展名.properties。如果设置了变体但找不到文件,则会回退到只使用国家代码。如果连国家代码也找不到,则会回退到原始默认设置。表 3-2 显示了各种语言环境的示例。

请注意,Android 应用程序通常使用类似的机制(但是使用 XML 格式的文件而不是 Java 属性文件,并且在找到属性文件的文件名时进行了一些小的更改)。

表 3-2. 不同语言环境的属性文件名

语言环境 文件名
默认语言环境 Menus.Properties
瑞典语 Menus_sv.properties
西班牙语 Menus_es.properties
法语 Menus_fr.properties
加拿大法语 Menus_fr_CA.properties

语言环境名称是两个字母的 ISO-639 语言代码(小写),通常是国家的本土名称(其语言使用者称呼它的名称)的缩写;因此,瑞典是sv表示Sverige,西班牙是es表示Español,等等。语言环境变体是两个字母的 ISO 国家代码(大写);例如,CA 表示加拿大,US 表示美国,SV 表示瑞典,ES 表示西班牙,等等。

设置语言环境

在 Windows 上,进入控制面板中的区域设置。更改此设置可能需要重新启动,因此请关闭所有编辑器窗口。

在 Unix 上,设置您的LANG环境变量。例如,墨西哥的 Korn shell 用户可能在她的.profile文件中有如下行:

export LANG=es_MX

在任一系统上,要测试不同的语言环境,只需在运行时通过命令行选项-D定义语言环境,例如:

java -Duser.language=es i18n.Browser

在西班牙语环境中运行名为Browser的 Java 程序,位于i18n包中。

您可以通过调用Locale.getAvailable​Lo⁠cales()获取可用语言环境的列表。

3.12 使用特定的语言环境

问题

在特定操作中,您可能希望使用除默认语言环境之外的语言环境。

解决方案

使用预定义实例或Locale构造函数来获取Locale。如果需要,可以使用Locale.setDefault(newLocale)将其设置为应用程序的全局默认值。

讨论

提供格式化服务的类,如DateTimeFormatterNumberFormat,提供了重载方法,可以带有或不带有与Locale相关的参数来调用。

要获取Locale对象,可以使用Locale类提供的预定义语言变量之一,或者构造自己的Locale对象,提供语言代码和国家代码:

Locale locale1 = Locale.FRANCE;    // predefined
Locale locale2 = new Locale("en", "UK");    // English, UK version

这些语言环境可以在各种格式化操作中使用:

DateFormat frDateFormatter = DateFormat.getDateInstance(
		DateFormat.MEDIUM, frLocale);
DateFormat ukDateFormatter = DateFormat.getDateInstance(
		DateFormat.MEDIUM, ukLocale);

可以使用其中任何一种来格式化日期或数字,如Use​Lo⁠cales类中所示:

package i18n;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;

/** Use some locales; based on user's OS "settings"
 * choices or -Duser.lang= or -Duser.region=.
 */
public class UseLocales {
    public static void main(String[] args) {

        Locale frLocale = Locale.FRANCE;    // predefined
        Locale ukLocale = new Locale("en", "UK");    // English, UK version

        DateTimeFormatter defaultDateFormatter =
            DateTimeFormatter.ofLocalizedDateTime(
                FormatStyle.MEDIUM);
        DateTimeFormatter frDateFormatter =
            DateTimeFormatter.ofLocalizedDateTime(
                FormatStyle.MEDIUM).localizedBy(frLocale);
        DateTimeFormatter ukDateFormatter =
            DateTimeFormatter.ofLocalizedDateTime(
                FormatStyle.MEDIUM).localizedBy(ukLocale);

        LocalDateTime now = LocalDateTime.now();
        System.out.println("Default: " + ' ' +
            now.format(defaultDateFormatter));
        System.out.println(frLocale.getDisplayName() + ' ' +
            now.format(frDateFormatter));
        System.out.println(ukLocale.getDisplayName() + ' ' +
            now.format(ukDateFormatter));
    }
}

程序在每个区域设置中打印区域名称并格式化日期:

$ java i18n.UseLocales
Default:  Oct 16, 2019, 4:41:45 PM
French (France) 16 oct. 2019 à 16:41:45
English (UK) Oct 16, 2019, 4:41:45 PM$

3.13 创建资源包

问题

您需要为国际化(I18N)创建一个资源包。

解决方案

资源包只是名称和值的集合。您可以编写一个java.util.ResourceBundle子类,但创建文本Properties文件(参见 Recipe 7.10)然后使用ResourceBundle.getBundle()加载它们更容易。可以使用任何纯文本编辑器创建这些文件。将其保留在文本文件格式中还允许桌面应用程序中的用户进行自定义;不提供其语言或希望根据方言的地方变化更改措辞的用户应能够编辑文件。

请注意,资源包文本文件不应与任何您的 Java 类具有相同的名称。原因是ResourceBundle动态构建一个与资源文件同名的类。

讨论

这里是一份用于几个菜单项的示例属性文件:

# Default Menu properties
# The File Menu
file.label=File Menu
file.new.label=New File
file.new.key=N
file.save.label=Save
file.new.key=S

创建默认属性文件通常不是问题,但为其他语言创建属性文件可能会是问题。除非您是一家大型跨国公司,否则可能没有资源(请原谅双关语)在内部创建资源文件。如果您正在发布商业软件或利用全球网络,您需要确定目标市场并了解其中哪些最关注希望以其语言显示菜单等内容。然后,聘请专业的翻译服务公司,该公司具有编制所需语言文件的专业知识。在发货前务必进行充分测试,就像您对软件的任何其他部分所做的那样。

如果您需要特殊字符、多行文本或其他复杂条目,请记住ResourceBundle也是Properties文件,因此请参阅java.util.Properties的文档。

3.14 程序:一个简单的文本格式化程序

此程序是一个简单的文本格式化程序,代表了在大多数计算平台上在独立图形化字处理器、激光打印机,以及最终桌面出版和办公套件兴起之前人们所使用的程序。它简单地从先前用文本编辑器创建的文件中读取单词,并在达到右边距时输出这些单词,然后调用println()以添加行结束符。例如,这是一个输入文件:

It's a nice
day, isn't it, Mr. Mxyzzptllxy?
I think we should
go for a walk.

给定该文件作为输入,Fmt程序会将格式化后的行打印出来:

It's a nice day, isn't it, Mr. Mxyzzptllxy? I think we should go for a
walk.

正如您所见,它将我们提供的文本适应到边距,并且丢弃原始文本中的所有换行符。以下是代码:

public class Fmt {
    /** The maximum column width */
    public static final int COLWIDTH=72;
    /** The file that we read and format */
    final BufferedReader in;
    /** Where the output goes */
    PrintWriter out;

    /** If files present, format each one, else format the standard input. */
    public static void main(String[] av) throws IOException {
        if (av.length == 0)
            new Fmt(System.in).format();
        else for (String name : av) {
            new Fmt(name).format();
        }
    }

    public Fmt(BufferedReader inFile, PrintWriter outFile) {
        this.in = inFile;
        this.out = outFile;
    }

    public Fmt(PrintWriter out) {
        this(new BufferedReader(new InputStreamReader(System.in)), out);
    }

    /** Construct a Formatter given an open Reader */
    public Fmt(BufferedReader file) throws IOException {
        this(file, new PrintWriter(System.out));
    }

    /** Construct a Formatter given a filename */
    public Fmt(String fname) throws IOException {
        this(new BufferedReader(new FileReader(fname)));
    }

    /** Construct a Formatter given an open Stream */
    public Fmt(InputStream file) throws IOException {
        this(new BufferedReader(new InputStreamReader(file)));
    }

    /** Format the File contained in a constructed Fmt object */
    public void format() throws IOException {
        format(in.lines(), out);
    }

    /** Format a Stream of lines, e.g., bufReader.lines() */
    public static void format(Stream<String> s, PrintWriter out) {
        StringBuilder outBuf = new StringBuilder();
        s.forEachOrdered((line -> {
            if (line.length() == 0) {    // null line
                out.println(outBuf);    // end current line
                out.println();    // output blank line
                outBuf.setLength(0);
            } else {
                // otherwise it's text, so format it.
                StringTokenizer st = new StringTokenizer(line);
                while (st.hasMoreTokens()) {
                    String word = st.nextToken();

                    // If this word would go past the margin,
                    // first dump out anything previous.
                    if (outBuf.length() + word.length() > COLWIDTH) {
                        out.println(outBuf);
                        outBuf.setLength(0);
                    }
                    outBuf.append(word).append(' ');
                }
            }
        }));
        if (outBuf.length() > 0) {
            out.println(outBuf);
        } else {
            out.println();
        }
    }

}

这个程序的稍微花哨一点的版本,Fmt2,位于这本书的在线源代码中。它使用dot commands——以句点开头的行——来对格式进行有限控制。一系列点命令格式化程序包括 Unix 的roffnroff, troffgroff,它们与 Digital Equipment 系统上称为runoff的程序处于同一系列中。这个的原始版本是 J. Saltzer 的runoff,它首先出现在 Multics 上,然后传入各种操作系统。为了节约树木,我没有在这里包含Fmt2;它是Fmt的子类,并重写了format()方法以包含附加功能(源代码在本书的完整javasrc代码库中)。

3.15 程序:Soundex 姓名比较

比较美式姓名的困难启发了美国人口调查局在 20 世纪初开发了 Soundex 算法。给定一组辅音,每个辅音都映射到一个特定的数字,其效果是将发音相似的姓名归为一类,因为在那个年代许多人不识字,无法一致拼写他们的姓氏。但是即使今天,例如在公司范围内的电话簿应用中,它仍然很有用。姓名 Darwin 和 Derwin 映射为 D650,而 Darwent 映射为 D653,这使得它邻近 D650。所有这些都被认为是相同姓名的历史变体。假设我们需要将包含这些姓名的行进行排序:如果我们可以在每行开头输出 Soundex 号码,这将很容易。以下是Soundex类的简单演示:

public class SoundexSimple {

    /** main */
    public static void main(String[] args) {
        String[] names = {
            "Darwin, Ian",
            "Davidson, Greg",
            "Darwent, William",
            "Derwin, Daemon"
        };
        for (String name : names) {
            System.out.println(Soundex.soundex(name) + ' ' + name);
        }
    }
}

让我们来运行它:

> javac -d . SoundexSimple.java
> java strings.SoundexSimple | sort
D132 Davidson, Greg
D650 Darwin, Ian
D650 Derwin, Daemon
D653 Darwent, William
>

正如你所见,Darwin-variant 姓名(包括 Daemon Derwin^(6))都被一起排序,并且与 Davidson(以及 Davis,Davies 等)的姓名在简单字母排序时出现在 Darwin 和 Derwin 之间的姓名是不同的。Soundex 算法已经完成了它的工作。

这里是Soundex类本身——它使用StringStringBuilder将姓名转换为 Soundex 代码:

main/src/main/java/strings/Soundex.java

public class Soundex {

    static boolean debug = false;

    /* Implements the mapping
 * from: AEHIOUWYBFPVCGJKQSXZDTLMNR
 * to:   00000000111122222222334556
 */
    public static final char[] MAP = {
        //A  B   C   D   E   F   G   H   I   J   K   L   M
        '0','1','2','3','0','1','2','0','0','2','2','4','5',
        //N  O   P   W   R   S   T   U   V   W   X   Y   Z
        '5','0','1','2','6','2','3','0','1','0','2','0','2'
    };

    /** Convert the given String to its Soundex code.
 * @return null If the given string can't be mapped to Soundex.
 */
    public static String soundex(String s) {

        // Algorithm works on uppercase (mainframe era).
        String t = s.toUpperCase();

        StringBuilder res = new StringBuilder();
        char c, prev = '?', prevOutput = '?';

        // Main loop: find up to 4 chars that map.
        for (int i=0; i<t.length() && res.length() < 4 &&
            (c = t.charAt(i)) != ','; i++) {

            // Check to see if the given character is alphabetic.
            // Text is already converted to uppercase. Algorithm
            // only handles ASCII letters, do NOT use Character.isLetter()!
            // Also, skip double letters.
            if (c>='A' && c<='Z' && c != prev) {
                prev = c;

                // First char is installed unchanged, for sorting.
                if (i==0) {
                    res.append(c);
                } else {
                    char m = MAP[c-'A'];
                    if (debug) {
                        System.out.println(c + " --> " + m);
                    }
                    if (m != '0' && m != prevOutput) {
                        res.append(m);
                        prevOutput = m;
                    }
                }
            }
        }
        if (res.length() == 0)
            return null;
        for (int i=res.length(); i<4; i++)
            res.append('0');
        return res.toString();
    }

显然,这个应用程序没有实现完整的 Soundex 算法的一些细微差别。使用 JUnit 进行更完整的测试(参见 Recipe 1.10)也可以在线进行,名为SoundexTest.java,位于src/tests/java/strings目录中。热心的读者可以使用此来引发这些细微差别的失败,并发送拉取请求,更新测试和代码的版本。

参见

Levenshtein 字符串编辑距离算法可以用于以不同方式进行近似字符串比较。你可以在Apache Commons StringUtils中找到这个算法。我展示了这个算法的非 Java(Perl)实现,在 Recipe 18.5 中可以找到。

¹ 这两个.equals()调用在除了第一个可能会抛出NullPointerException之外是等价的。

² StringBuilder 是在 Java 5 中添加的。它在功能上等同于旧版的 StringBuffer。我们将在 Recipe 3.2 中详细讨论细节。

³ 除非,也许,你更新个人记录的速度和我一样慢。

StringStringBuilder 在它们实现的 CharSequence 接口中有几个强制相同的方法。

⁵ 实际上,Unicode 中有如此多的字符,以至于出现了一种潮流,使用近似拉丁字母的倒置版本字符显示你的名字倒置。在网络上搜索“倒置 Unicode”即可了解更多。

⁶ 在 Unix 术语中,守护进程(daemon)是一个服务器。这个古老的英文单词与撒旦的恶魔无关,而是指一个帮助者或助手。Derwin Daemon 实际上是 Susannah Coleman 的 Source Wars 在线漫画中的一个角色,很久以前曾经在一个现在已经关闭的网站 darby.daemonnews.org 上在线。

第四章:使用正则表达式进行模式匹配

4.0 简介

假设你已经在互联网上几年了,并且一直在保留所有通讯记录,以防万一您(或您的律师或检察官)需要一份副本。结果是您有一个专门用于保存邮件的 5 GB 磁盘分区。让我们进一步假设您记得在其中的某个位置有一封来自某个名叫 Angie 或 Anjie 的人的电子邮件。还是说是 Angy?但您不记得您如何称呼它或者您将其存储在何处。显然,您得去找它。

但是当你们中的一些人试图在文字处理器中打开所有 15,000,000 个文档时,我只需用一个简单的命令找到它。任何提供正则表达式支持的系统都允许我以几种方式搜索模式。最简单易懂的是:

Angie|Anjie|Angy

您可能猜到这意味着只需搜索任何变体。更简洁的形式(更多思考,更少打字)是:

An[^ dn]

语法将在我们进行本章讨论时变得清晰。简而言之,“A”和“n”会自动匹配它们自己,实际上找到以“An”开头的单词,而神秘的[^ dn]要求“An”后面跟着一个不是(^在这里的意思是not)空格的字符(以消除在句子开头非常常见的英语单词“an”)或“d”(以消除常见词“and”)或“n”(以消除“Anne”、“Announcing”等)。您的文字处理器是否已经跳过了它的闪屏页面?好吧,这没关系,因为我已经找到了丢失的文件。要找到答案,我只需输入这个命令:

grep 'An[^ dn]' *

正则表达式,或称为regexes,提供了一种简洁而精确的模式规范,用于在文本中匹配。一种好的理解正则表达式的方式是它是用于在字符串中匹配字符模式的一种小语言。正则表达式 API 是用于匹配正则表达式的解释器

另一个正则表达式强大之处的例子是考虑大规模更新数百个文件的问题。当我开始学习 Java 时,声明数组引用的语法是baseType arrayVariableName[]。例如,一个带有数组参数的方法,例如每个程序的主方法,通常是这样写的:

public static void main(String args[]) {

但随着时间的推移,Java 语言的管理者们认识到将其写为baseType[] arrayVariableName会更好,就像这样:

public static void main(String[] args) {

这是更好的 Java 风格,因为它将类型的“数组性”与类型本身关联起来,而不是与局部参数名关联,并且编译器仍然接受这两种模式。我希望将所有以旧方式编写的main更改为新方式。我使用了模式main(String [a-z],通过之前描述的grep实用程序查找所有包含旧式主体声明(即main(String后跟一个空格和一个名称字符而不是一个开方括号)的文件名。然后,我在一个小的 shell 脚本中使用另一个基于正则表达式的 Unix 工具流编辑器sed来更改这些文件中所有出现的内容,从main(String *([a-z][a-z]*)[]main(String[] $1(此处使用的正则表达式语法在本章后面讨论)。同样,基于正则表达式的方法比交互式地进行操作快得多,即使使用像viemacs这样的强大编辑器,更不用说尝试使用图形化的文字处理器了。

历史上,随着正则表达式被越来越多的工具和编程语言所采纳,其语法也发生了变化,因此先前示例中的确切语法并不完全适用于 Java,但它确实传达了正则表达式机制的简洁性和强大性。¹

第三个例子是解析 Apache Web 服务器日志文件,其中一些字段用引号分隔,另一些用方括号分隔,还有些用空格分隔。在任何语言中编写解析代码都很混乱,但是一个精心设计的正则表达式可以在一次操作中将行分解为所有组成部分(此示例在配方 4.10 中开发)。

Java 开发人员也可以获得同样的时间节省。正则表达式支持已经在标准 Java 运行时中存在多年,并且得到了很好的集成(例如,标准类java.lang.String和新 I/O 包中都有正则表达式方法)。Java 还有一些其他正则表达式包,偶尔可能会遇到使用它们的代码,但几乎所有本世纪的代码都可以预期使用内置包。Java 正则表达式的语法本身在配方 4.1 中讨论,使用正则表达式的 Java API 的语法在配方 4.2 中描述。其余的配方展示了 Java 中正则表达式技术的一些应用。

参见

精通正则表达式 由 Jeffrey Friedl(O’Reilly)是正则表达式所有细节的权威指南。Unix 和 Perl 的大多数入门书籍都包含对正则表达式的讨论;Unix 权威指南 由 Mike Loukides、Tim O’Reilly、Jerry Peek 和 Shelley Powers(O’Reilly)专门有一章介绍正则表达式。

4.1 正则表达式语法

问题

您需要学习 Java 正则表达式的语法。

解决方案

参考 Table 4-1 获取正则表达式字符列表。

讨论

这些模式字符允许您指定具有相当强大的正则表达式。在构建模式时,您可以使用任何普通文本和元字符或特殊字符的组合,在 Table 4-1 中。这些可以以任何合理的组合方式使用。例如,a+ 表示字母 a 的任意次数,从一次到百万次或无限多次。模式 Mrs?. 匹配 Mr.Mrs..* 表示任意字符,任意次数,类似于大多数命令行解释器对单独的 \* 的含义。模式 \d+ 表示任意数量的数字。\d{2,3} 表示二位或三位数字。

Table 4-1. 正则表达式元字符语法

子表达式 匹配 注释
通用
\^ 行/字符串的起始位置
` 子表达式 匹配
--- --- ---
通用
\^ 行/字符串的起始位置
行/字符串的结束位置
\b 单词边界
\B 非单词边界
\A 整个字符串的开始
\z 整个字符串的结束
\Z 整个字符串的结束(除了允许的最终行终止符) 参见 Recipe 4.9
. 任一字符(不包括行终止符)
[…] “字符类”;包含列出的任一字符
[\^…] 不包含列出的任一字符 参见 Recipe 4.2
替代和分组
(…) 分组(捕获组) 参见 Recipe 4.3
&#124; 替代
(?:re ) 非捕获括号
\G 前一次匹配的结束
++n 回溯引用到捕获组编号 n
普通(贪婪)量词
{ m,n } mn 次重复的量词 参见 Recipe 4.4
{ m ,} 至少 m 次重复的量词
{ m } 正好 m 次重复的量词 参见 Recipe 4.10
{,n } 0 到 n 次重复的量词
\* 0 或多次重复的量词 等同于 {0,}
+ 1 次或更多次重复的量词 等同于 {1,};参见 Recipe 4.2
? 0 或 1 次重复的量词(即,确切出现一次,或者根本不出现) 等同于 {0,1}
懒惰(非贪婪)量词
{ m,n }? 懒惰量词,从 mn 次重复
{ m,}?` 懒惰量词,至少 m 次重复
{,n }? 懒惰量词,从 0 到 n 次重复
\*? 懒惰量词:0 或多次
+? 懒惰量词:1 次或更多次 参见 Recipe 4.10
?? 懒惰量词:0 或 1 次
占有型(非常贪婪)量词
{ m,n }+ mn次重复的占有量词
{ m ,}+ 至少m次重复的占有量词
{,n }+ 0 到n次重复的占有量词
\*+ 占有量词:0 或更多
++ 占有量词:1 次或更多
?+ 占有量词:0 或 1 次
转义和速记
\ 转义(引号)字符:关闭大多数元字符;将后续字母转换为元字符
\Q 转义(引用)直到\E的所有字符
\E 结束以\Q开始的引用
\t 制表符
\r 回车(换行回车)字符
\n 换行符 参见 Recipe 4.9
\f 换页符
\w 单词中的字符 使用\w+表示一个单词;参见 Recipe 4.10
\W 非单词字符
\d 数字字符 使用\d+表示整数;参见 Recipe 4.2
\D 非数字字符
\s 空白字符 空格、制表符等,由java.lang.Character.isWhitespace()确定
\S 非空白字符 参见 Recipe 4.10
Unicode 区块(代表性样本)
\p{InGreek} 希腊区块中的字符 (简单区块)
\P{InGreek} 不在希腊区块中的任何字符
\p{Lu} 大写字母 (简单类别)
\p{Sc} 货币符号
POSIX 风格的字符类(仅适用于 US-ASCII)
\p{Alnum} 字母数字字符 [A-Za-z0-9]
\p{Alpha} 字母字符 [A-Za-z]
\p{ASCII} 任何 ASCII 字符 [\x00-\x7F]
\p{Blank} 空格和制表符字符
\p{Space} 空格字符 [ \t\n\x0B\f\r]
\p{Cntrl} 控制字符 [\x00-\x1F\x7F]
\p{Digit} 数字字符 [0-9]
\p{Graph} 可打印且可见字符(非空格或控制字符)
\p{Print} 可打印字符 \p{Graph}相同
\p{Punct} 标点字符 !"#$%&'()\*+,-./:;<=>?@[]\^_~`
\p{Lower} 小写字符 [a-z]
\p{Upper} 大写字符 [A-Z]
\p{XDigit} 十六进制数字字符 [0-9a-fA-F]

正则表达式尽可能在字符串中匹配任何位置。紧随贪婪量词(在传统 Unix 正则表达式中唯一存在的类型)的模式尽可能多地消耗(匹配),而不会影响接下来的子表达式。紧随占有量词的模式尽可能多地匹配,而不考虑接下来的子表达式。紧随懒惰量词的模式尽可能少地消耗字符,以便仍然能够匹配。

此外,与其他一些语言中的正则表达式包不同,Java 正则表达式包从一开始就被设计用来处理 Unicode 字符。标准的 Java 转义序列\u+nnnn用于在模式中指定 Unicode 字符。我们使用java.lang.Character的方法来确定 Unicode 字符的属性,例如给定字符是否为空格。再次注意,如果这是在编译中的 Java 字符串中,则必须加倍反斜杠,因为编译器否则会将其解析为“反斜杠-u”后跟一些数字。

为了帮助您了解正则表达式的工作原理,我提供了一个名为 REDemo 的小程序。² REDemo 的代码太长了,无法包含在本书中;您可以在 darwinsys-api 仓库的 regex 目录中找到 REDemo.java,您可以运行它来探索正则表达式的工作原理。

在最上面的文本框中(参见 Figure 4-1),键入您要测试的正则表达式模式。请注意,当您键入每个字符时,都会检查正则表达式的语法;如果语法正确,您会看到其旁边有一个复选标记。然后,您可以选择匹配、查找或查找所有。匹配意味着整个字符串必须与正则表达式匹配,而查找意味着正则表达式必须在字符串中的某个位置找到(查找所有会计算找到的出现次数)。在下方,您键入要与正则表达式匹配的字符串。尽情实验。当您将正则表达式调整到想要的方式时,您可以将其粘贴到您的 Java 程序中。您需要转义(反斜杠)任何由 Java 编译器和 Java 正则表达式包同时特殊处理的字符,例如反斜杠本身、双引号等。一旦您获得想要的正则表达式,就有一个复制按钮(在这些截图中未显示)可将正则表达式导出到剪贴板上,根据您希望如何使用它进行反斜杠加倍或不加倍。

提示

请记住,因为正则表达式是作为将由 Java 编译器编译的字符串输入的,通常需要两个级别的转义以处理任何特殊字符,包括反斜杠和双引号。例如,正则表达式(其中包含双引号):

"You said it\."

必须像这样键入才能成为有效的编译时 Java 语言 String

String pattern = "\"You said it\\.\""

在 Java 14+ 中,您也可以使用文本块来避免转义引号:

String pattern = """
	"You said it\\.""""

我无法告诉你我有多少次犯了忘记在\d+\w+及其类似形式中添加额外反斜杠的错误!

在 Figure 4-1 中,我在 REDemo 程序的 Pattern 框中键入了 qu,这是一个语法上有效的正则表达式模式:任何普通字符都是其自身的正则表达式,因此这将查找字母 q 后跟 u。在顶部版本中,我只键入了一个 q 到字符串中,这是不匹配的。在第二个版本中,我键入了 quack 和第二个 quackq。因为我已经选择了查找所有,所以计数显示一个匹配项。当我键入第二个 u 时,计数将更新为两个,如第三个版本所示。

正则表达式不仅可以进行字符匹配。例如,两个字符的正则表达式 ^T 将匹配行的开头 (^) 立即跟随一个大写字母 T —— 也就是说,任何以大写字母 T 开头的行都会匹配。无论行是否以“Tiny trumpets,” “Titanic tubas,” 或 “Triumphant twisted trombones” 开头,只要第一个位置有大写字母 T 即可。

但是,我们目前并没有取得很大进展。我们真的要投入所有这些精力来开发正则表达式技术,只是为了能够做到使用 java.lang.StringstartsWith() 方法已经可以做到的事情吗?嗯,我能听到一些人开始有点不耐烦了。请坐好!如果你想要匹配不仅在第一个位置有字母 T,而且紧随其后立即有元音字母,并且后面有任意数量的单词中的字母,最后是一个感叹号呢?在 Java 中,你肯定可以通过检查 startsWith("T")charAt(1) == 'a' || charAt(1) == 'e' 等来实现。是的,但是当你这样做的时候,你会写很多非常专门化的代码,这些代码在其他应用程序中无法使用。使用正则表达式,你只需给出模式 ^T[aeiou]\w*!。也就是说,与之前一样的 ^T,后跟列出元音字母的字符类,然后是任意数量的单词字符 (\w*),最后是感叹号。

jcb4 0401

图 4-1. REDemo with simple examples

“但等等,还有更多!” 我已故的伟大老板 Yuri Rubinsky 曾经说过。如果你想要能够在 运行时 更改你正在寻找的模式呢?还记得你刚刚编写的所有 Java 代码来匹配第一列的 T,加上一个元音字母,一些单词字符,以及一个感叹号吗?好吧,现在是时候将它们扔掉了。因为今天早上我们需要匹配 Q,后跟一个不是 u 的字母,然后是一些数字,最后是一个句点。当一些人开始编写一个新的函数来实现这个目标时,我们其他人只需漫步到 RegEx 酒吧和餐厅,向酒保点一杯 ^Q[^u]\d+\. 就可以继续我们的工作了。

好的,如果您需要解释:[^u] 表示匹配任何一个不是字符 u 的字符。\d+ 表示一个或多个数字。+ 是一个量词,表示它后面的内容出现一次或多次,而 \d 表示任意一个数字。所以 \d+ 表示一个、两个或更多位数的数字。最后,\.?嗯,. 本身是一个元字符。大多数单个元字符在前面加上转义字符就会被禁用。当然不是键盘上的 Esc 键。正则表达式的转义字符是反斜杠。在元字符(如.)前面加上这个转义字符会禁用它的特殊含义,因此我们寻找的是一个字面上的句点而不是任何字符。在前面加上几个选定的字母字符(例如 nrtsw)的转义字符会将它们转换为元字符。图 4-2 展示了 ^Q[^u]\d+\.. 正则表达式的应用。在第一帧中,我已经输入了正则表达式的一部分 ^Q[^u。因为有一个未关闭的方括号,语法 OK 标志被关闭了;当我完成正则表达式时,它将被重新打开。在第二帧中,我已经完成了正则表达式的输入,并且我已经输入了数据字符串 QA577(您应该期望它匹配 $$^Q[^u]\d+$$,但不包括句点,因为我还没有输入它)。在第三帧中,我输入了句点,因此匹配标志设置为是。

jcb4 0402

图 4-2. REDemo 示例:“不跟随 u 的 Q”

因为在将正则表达式粘贴到 Java 代码中时需要转义反斜杠,所以当前版本的 REDemo 有一个 复制模式 按钮,它将正则表达式原样复制以供文档和 Unix 命令使用,并且有一个 复制模式(反斜杠) 按钮,它将正则表达式复制到剪贴板上并将反斜杠加倍,以便粘贴到 Java 字符串中。

到目前为止,您至少应该基本掌握了正则表达式在实践中的工作原理。本章的其余部分将提供更多示例,并解释一些更强大的主题,例如捕获组。至于正则表达式在理论上的工作原理——不同的正则表达式风格之间有很多理论细节和差异——对于感兴趣的读者,建议参考 Mastering Regular Expressions。同时,让我们开始学习如何编写使用正则表达式的 Java 程序的方法。

4.2 在 Java 中使用正则表达式:测试一个模式

问题

现在,您可以开始使用正则表达式处理来增强您的 Java 代码,测试给定模式是否可以在给定字符串中匹配。

解决方案

使用 Java 正则表达式包,java.util.regex

讨论

好消息是,Java 的正则表达式 API 实际上非常易于使用。如果您只需要查找给定的正则表达式是否与字符串匹配,可以使用 String 类的便捷方法 boolean matches(),它接受一个以字符串形式表示的正则表达式模式作为其参数:

if (inputString.matches(stringRegexPattern)) {
    // it matched... do something with it...
}

然而,这只是一个便利程序,而便利总是有代价的。如果正则表达式在程序中要使用一次或两次以上,构建并使用Pattern及其Matcher更有效率。一个完整的程序示例如下,构建Pattern并使用它进行match

public class RESimple {
    public static void main(String[] argv) {
        String pattern = "^Q[^u]\\d+\\.";
        String[] input = {
            "QA777\. is the next flight. It is on time.",
            "Quack, Quack, Quack!"
        };

        Pattern p = Pattern.compile(pattern);

        for (String in : input) {
            boolean found = p.matcher(in).lookingAt();

            System.out.println("'" + pattern + "'" +
            (found ? " matches '" : " doesn't match '") + in + "'");
        }
    }
}

java.util.regex包含两个类,PatternMatcher,提供了示例Example 4-1中显示的公共 API。

示例 4-1. 正则表达式公共 API
/**
 * The main public API of the java.util.regex package.
 */

package java.util.regex;

public final class Pattern {
    // Flags values ('or' together)
    public static final int
        UNIX_LINES, CASE_INSENSITIVE, COMMENTS, MULTILINE,
        DOTALL, UNICODE_CASE, CANON_EQ;
    // No public constructors; use these Factory methods
    public static Pattern compile(String patt);
    public static Pattern compile(String patt, int flags);
    // Method to get a Matcher for this Pattern
    public Matcher matcher(CharSequence input);
    // Information methods
    public String pattern();
    public int flags();
    // Convenience methods
    public static boolean matches(String pattern, CharSequence input);
    public String[] split(CharSequence input);
    public String[] split(CharSequence input, int max);
}

public final class Matcher {
    // Action: find or match methods
    public boolean matches();
    public boolean find();
    public boolean find(int start);
    public boolean lookingAt();
    // "Information about the previous match" methods
    public int start();
    public int start(int whichGroup);
    public int end();
    public int end(int whichGroup);
    public int groupCount();
    public String group();
    public String group(int whichGroup);
    // Reset methods
    public Matcher reset();
    public Matcher reset(CharSequence newInput);
    // Replacement methods
    public Matcher appendReplacement(StringBuffer where, String newText);
    public StringBuffer appendTail(StringBuffer where);
    public String replaceAll(String newText);
    public String replaceFirst(String newText);
    // information methods
    public Pattern pattern();
}

/* String, showing only the RE-related methods */
public final class String {
    public boolean matches(String regex);
    public String replaceFirst(String regex, String newStr);
    public String replaceAll(String regex, String newStr);
    public String[] split(String regex);
    public String[] split(String regex, int max);
}

此 API 足够大,需要一些解释。这些是生产程序中正则表达式匹配的常规步骤:

  1. 通过调用静态方法Pattern.compile()来创建Pattern

  2. 对每个希望查找的String(或其他CharSequence),通过调用pattern.matcher(CharSequence)从模式中请求一个Matcher

  3. 调用(一次或多次)结果Matcher中的一个查找方法(稍后在本节中讨论)。

java.lang.CharSequence接口提供对包含字符集合的对象的简单只读访问。标准实现包括StringStringBuffer/StringBuilder(在Chapter 3中描述)以及新的 I/O 类java.nio.CharBuffer

当然,您可以以其他方式执行正则表达式匹配,例如使用Pattern中的便利方法或甚至在java.lang.String中,如下所示:

public class StringConvenience {
    public static void main(String[] argv) {

        String pattern = ".*Q[^u]\\d+\\..*";
        String line = "Order QT300\. Now!";
        if (line.matches(pattern)) {
            System.out.println(line + " matches \"" + pattern + "\"");
        } else {
            System.out.println("NO MATCH");
        }
    }
}

但是,三步列表是匹配的标准模式。如果正则表达式仅在程序中使用一次,可能会使用String便利程序;如果正则表达式使用超过一次,则值得花时间编译它,因为编译版本运行更快。

此外,Matcher具有多个查找方法,提供比String便利程序match()更灵活的功能。这些是Matcher的方法:

match()

用于将整个字符串与模式进行比较;这与java.lang.String中的常规程序相同。因为它匹配整个String,所以我必须在模式之前和之后放置.*

lookingAt()

仅用于匹配字符串的开头。

find()

用于在字符串中匹配模式(不一定在字符串的第一个字符处),从字符串的开头开始,或者如果先前调用该方法并成功,则从未由上一个匹配项匹配的第一个字符开始。

每种方法都返回boolean,其中true表示匹配,false表示不匹配。要检查给定字符串是否与给定模式匹配,只需输入如下内容:

Matcher m = Pattern.compile(patt).matcher(line);
if (m.find( )) {
    System.out.println(line + " matches " + patt)
}

但您可能还想提取匹配的文本,这是下一个配方的主题。

以下示例涵盖了 Matcher API 的用法。最初,示例仅使用String类型的参数作为输入源。其他CharSequence类型的使用在Recipe 4.5中介绍。

4.3 寻找匹配的文本

问题

您需要找到正则表达式匹配的文本。

解决方案

有时候你不仅需要知道正则表达式是否匹配了字符串。在编辑器和许多其他工具中,您需要知道确切匹配了哪些字符。请记住,对于像*的量词,匹配的文本长度可能与匹配它的模式长度没有关系。不要低估强大的.*,如果允许的话,它可以轻松匹配成千上万个字符。正如您在前面的示例中看到的,您可以仅通过使用find()matches()来确定给定匹配是否成功。但在其他应用程序中,您可能需要获取模式匹配的字符。

在前述方法成功调用后,您可以使用Matcher上的这些信息方法获取有关匹配的信息:

start(), end()

返回字符串中匹配的起始和结束字符的字符位置。

groupCount()

返回括号分组(如果有的话)的数量;如果未使用任何组,则返回 0。

group(int i)

返回当前匹配的第i组匹配的字符,如果i大于等于零且小于等于groupCount()的返回值。组 0 是整个匹配,因此group(0)(或只是group())返回匹配输入的整个部分。

括号或捕获组的概念是正则表达式处理的核心。正则表达式可以嵌套到任意复杂的级别。group(int)方法允许您检索匹配给定括号组的字符。如果您没有使用任何显式的括号,则可以将匹配的任何内容视为零级。Example 4-2 展示了REMatch.java的部分内容。

示例 4-2. main/src/main/java/regex/REMatch.java 的一部分
public class REmatch {
    public static void main(String[] argv) {

        String patt = "Q[^u]\\d+\\.";
        Pattern r = Pattern.compile(patt);
        String line = "Order QT300\. Now!";
        Matcher m = r.matcher(line);
        if (m.find()) {
            System.out.println(patt + " matches \"" +
                m.group(0) +
                "\" in \"" + line + "\"");
        } else {
            System.out.println("NO MATCH");
        }
    }
}

运行时,这将打印:

Q[\^u]\d+\. matches "QT300." in "Order QT300\. Now!"

通过选中Match按钮,REDemo 提供了给定正则表达式中所有捕获组的显示;其中一个示例显示在 Figure 4-3 中。

jcb4 0403

图 4-3. REDemo 的操作示例

还可以获取模式匹配的起始和结束索引以及文本的长度(请记住,例如此示例中的\d+等具有量词的术语可以匹配字符串中任意数量的字符)。可以与String.substring()方法结合使用,如下所示:

        String patt = "Q[^u]\\d+\\.";
        Pattern r = Pattern.compile(patt);
        String line = "Order QT300\. Now!";
        Matcher m = r.matcher(line);
        if (m.find()) {
            System.out.println(patt + " matches \"" +
                line.substring(m.start(0), m.end(0)) +
                "\" in \"" + line + "\"");
        } else {
            System.out.println("NO MATCH");
        }

假设您需要从字符串中提取多个项目。如果输入为

Smith, John
Adams, John Quincy

并且您希望退出

John Smith
John Quincy Adams

只需使用以下内容:

public class REmatchTwoFields {
    public static void main(String[] args) {
        String inputLine = "Adams, John Quincy";
        // Construct an RE with parens to "grab" both field1 and field2
        Pattern r = Pattern.compile("(.*), (.*)");
        Matcher m = r.matcher(inputLine);
        if (!m.matches())
            throw new IllegalArgumentException("Bad input");
        System.out.println(m.group(2) + ' ' + m.group(1));
    }
}

4.4 替换匹配的文本

问题

找到某个文本使用模式后,您希望用不同的文本替换该文本,而不干扰字符串的其余部分。

解决方案

正如我们在前面的示例中看到的,涉及量词的正则表达式模式可以匹配大量字符,而只需很少的元字符。我们需要一种方法来替换正则表达式匹配的文本,而不更改其前后的其他文本。我们可以手动使用 String 方法 substring() 来实现这一点。然而,由于这是一个如此常见的需求,Java 正则表达式 API 提供了一些替换方法。

讨论

Matcher 类提供了几种方法来仅替换匹配模式的文本。在所有这些方法中,你需要传入替换文本或“右侧”替换的内容(这个术语来自历史上的命令行文本编辑器的替换命令,左侧是模式,右侧是替换文本)。这些是替换方法:

replaceAll(newString)

替换了所有匹配的字符串

replaceFirst(newString)

与上例相同,但只替换第一个匹配项

appendReplacement(StringBuffer, newString)

复制直到第一个匹配项之前,再加上给定的 newString

appendTail(StringBuffer)

在最后一个匹配项后添加文本(通常用于 appendReplacement 后)

尽管它们的名称如此,replace* 方法的行为符合 Strings 的不可变性(参见 “Timeless, Immutable, and Unchangeable”):它们创建一个执行替换的新 String 对象;它们不会(事实上,也不能)修改 Matcher 对象中引用的字符串。

Example 4-3 展示了这三种方法的使用。

示例 4-3. main/src/main/java/regex/ReplaceDemo.java
/**
 * Quick demo of RE substitution: correct U.S. 'favor'
 * to Canadian/British 'favour', but not in "favorite"
 * @author Ian F. Darwin, http://www.darwinsys.com/
 */
public class ReplaceDemo {
    public static void main(String[] argv) {

        // Make an RE pattern to match as a word only (\b=word boundary)
        String patt = "\\bfavor\\b";

        // A test input
        String input = "Do me a favor? Fetch my favorite.";
        System.out.println("Input: " + input);

        // Run it from a RE instance and see that it works
        Pattern r = Pattern.compile(patt);
        Matcher m = r.matcher(input);
        System.out.println("ReplaceAll: " + m.replaceAll("favour"));

        // Show the appendReplacement method
        m.reset();
        StringBuffer sb = new StringBuffer();
        System.out.print("Append methods: ");
        while (m.find()) {
            // Copy to before first match,
            // plus the word "favor"
            m.appendReplacement(sb, "favour");
        }
        m.appendTail(sb);        // copy remainder
        System.out.println(sb.toString());
    }
}

当你运行它时,它确实按我们的预期执行:

Input: Do me a favor? Fetch my favorite.
ReplaceAll: Do me a favour? Fetch my favorite.
Append methods: Do me a favour? Fetch my favorite.

replaceAll() 方法处理了在整个字符串中进行相同更改的情况。如果你想要将每个匹配的出现更改为不同的值,可以在循环中使用 replaceFirst(),如在 Example 4-4 中所示。在这里,我们遍历整个字符串,将每个 catdog 的出现转换为 felinecanine。这是一个简化的实例,它查找了 bit.ly URL,并将其替换为实际的 URL;其中 computeReplacement 方法使用了来自 Recipe 12.1 的网络客户端代码。

示例 4-4. main/src/main/java/regex/ReplaceMulti.java
/**
 * To perform multiple distinct substitutions in the same String,
 * you need a loop, and must call reset() on the matcher.
 */
public class ReplaceMulti {
    public static void main(String[] args) {

        Pattern patt = Pattern.compile("cat|dog");
        String line = "The cat and the dog never got along well.";
        System.out.println("Input: " + line);
        Matcher matcher = patt.matcher(line);
        while (matcher.find()) {
            String found = matcher.group(0);
            String replacement = computeReplacement(found);
            line = matcher.replaceFirst(replacement);
            matcher.reset(line);
        }
        System.out.println("Final: " + line);
    }

    static String computeReplacement(String in) {
        switch(in) {
        case "cat": return "feline";
        case "dog": return "canine";
        default: return "animal";
        }
    }
}

如果你需要引用与正则表达式匹配的部分,可以在模式中用额外的括号标记它们,并在替换字符串中使用 $1$2 等来引用匹配的部分。Example 4-5 就是使用这种方法来交换两个字段,即将形如 Firstname Lastname 的姓名转换为 Lastname, FirstName

示例 4-5. main/src/main/java/regex/ReplaceDemo2.java
public class ReplaceDemo2 {
    public static void main(String[] argv) {

        // Make an RE pattern
        String patt = "(\\w+)\\s+(\\w+)";

        // A test input
        String input = "Ian Darwin";
        System.out.println("Input: " + input);

        // Run it from a RE instance and see that it works
        Pattern r = Pattern.compile(patt);
        Matcher m = r.matcher(input);
        m.find();
        System.out.println("Replaced: " + m.replaceFirst("$2, $1"));

        // The short inline version:
        // System.out.println(input.replaceFirst("(\\w+)\\s+(\\w+)", "$2, $1"));
    }
}

4.5 打印模式的所有出现

问题

你需要在一个或多个文件或其他来源中查找所有匹配给定正则表达式的字符串。

解决方案

此示例逐行读取文件。每当找到匹配项时,我从line中提取并打印它。

这段代码从 Recipe 4.3 中获取了group()方法,从CharacterIterator接口中获取了substring方法,并从正则表达式中获取了match()方法,然后将它们组合在一起。我编写它来从给定文件中提取所有名称;在运行程序时,它会打印出importjavauntilregex等单词,每个单词独占一行:

C:\> java ReaderIter.java ReaderIter.java
import
java
util
regex
import
java
io
Print
all
the
strings
that
match
given
pattern
from
file
public
...
C:\\>

我在这里中断了以节省纸张。这可以有两种写法:一种是逐行模式,如 Example 4-6 中所示,另一种是使用新 I/O 的更紧凑形式,如 Example 4-7 中所示(两个示例中使用的新 I/O 包在 Chapter 10 中描述)。

示例 4-6. main/src/main/java/regex/ReaderIter.java
public class ReaderIter {
    public static void main(String[] args) throws IOException {
        // The RE pattern
        Pattern patt = Pattern.compile("[A-Za-z][a-z]+");
        // See the I/O chapter
        // For each line of input, try matching in it.
        Files.lines(Path.of(args[0])).forEach(line -> {
            // For each match in the line, extract and print it.
            Matcher m = patt.matcher(line);
            while (m.find()) {
                // Simplest method:
                // System.out.println(m.group(0));

                // Get the starting position of the text
                int start = m.start(0);
                // Get ending position
                int end = m.end(0);
                // Print whatever matched.
                // Use CharacterIterator.substring(offset, end);
                System.out.println(line.substring(start, end));
            }
        });
    }
}
示例 4-7. main/src/main/java/regex/GrepNIO.java
public class GrepNIO {
    public static void main(String[] args) throws IOException {

        if (args.length < 2) {
            System.err.println("Usage: GrepNIO patt file [...]");
            System.exit(1);
        }

        Pattern p=Pattern.compile(args[0]);
        for (int i=1; i<args.length; i++)
            process(p, args[i]);
    }

    static void process(Pattern pattern, String fileName) throws IOException {

        // Get a FileChannel from the given file
        FileInputStream fis = new FileInputStream(fileName);
        FileChannel fc = fis.getChannel();

        // Map the file's content
        ByteBuffer buf = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());

        // Decode ByteBuffer into CharBuffer
        CharBuffer cbuf =
            Charset.forName("ISO-8859-1").newDecoder().decode(buf);

        Matcher m = pattern.matcher(cbuf);
        while (m.find()) {
            System.out.println(m.group(0));
        }
        fis.close();
    }
}

非阻塞 I/O(NIO)版本显示在 Example 4-7 中,它依赖于 NIO Buffer可以用作CharSequence的事实。这个程序更通用,因为它的模式参数来自命令行参数。如果以前一个程序的模式参数在命令行上调用,它将打印与前一个示例相同的输出:

java regex.GrepNIO "[A-Za-z][a-z]+"  ReaderIter.java

你可能会考虑使用\w+作为模式;唯一的区别是,我的模式寻找格式良好的大写单词,而\w+会包括 Java 中心的奇怪用法,如theVariableName,其中大写字母位于非标准位置。

同样注意,NIO 版本可能更有效,因为它不会像ReaderIter那样在每行输入时将Matcher重置为新的输入源。

4.6 打印包含模式的行

问题

你需要查找一个或多个文件中与给定正则表达式匹配的行。

解决方案

编写一个简单的类似于grep的程序。

讨论

正如我之前提到的,一旦你有了一个正则表达式包,你就可以编写类似于grep的程序。我之前给出了 Unix 的grep程序的示例。grep被调用时带有一些可选参数,后面跟着一个必需的正则表达式模式,然后是任意数量的文件名。它打印包含该模式的任何行,与 Recipe 4.5 不同,后者只打印匹配的文本本身。这里是一个例子:

grep "[dD]arwin" *.txt 

此代码搜索包含darwinDarwin的行,它位于文件名以.txt 结尾的每个文件的每一行中。³ 第一个版本的执行此操作的程序的源是 Example 4-8,名为Grep0。它从标准输入读取行,并且不接受任何可选参数,但它处理Pattern类实现的完整一套正则表达式(因此与同名的 Unix 程序不完全相同)。我们尚未涵盖用于输入和输出的java.io包(请参见 Chapter 10),但我们在此处的使用足够简单,您可能可以直观理解它。在线源包括Grep1,它执行相同操作但结构更好(因此更长)。本章后面的 Recipe 4.11 介绍了一个名为JGrep的程序,该程序解析一组命令行选项。

示例 4-8. main/src/main/java/regex/Grep0.java
public class Grep0 {
    public static void main(String[] args) throws IOException {
        BufferedReader is =
            new BufferedReader(new InputStreamReader(System.in));
        if (args.length != 1) {
            System.err.println("Usage: MatchLines pattern");
            System.exit(1);
        }
        Pattern patt = Pattern.compile(args[0]);
        Matcher matcher = patt.matcher("");
        String line = null;
        while ((line = is.readLine()) != null) {
            matcher.reset(line);
            if (matcher.find()) {
                System.out.println("MATCH: " + line);
            }
        }
    }
}

4.7 在正则表达式中控制大小写

问题

您希望无视大小写地查找文本。

解决方案

编译Pattern时,传入Pattern.CASE_INSENSITIVE作为flags参数,指示匹配应该是不区分大小写的(即应该忽略大小写的差异)。如果您的代码可能在不同的地区运行(参见 Recipe 3.12),那么应添加Pattern.UNICODE_CASE。如果没有这些标志,默认行为是普通的大小写敏感匹配行为。像这样将这些标志(和其他标志)传递给Pattern.compile()方法:

// regex/CaseMatch.java
Pattern  reCaseInsens = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE |
    Pattern.UNICODE_CASE);
reCaseInsens.matches(input);        // will match case-insensitively

在创建Pattern时必须传递此标志;因为Pattern对象是不可变的,一旦构建就无法更改。

此示例的完整源代码在线查看,文件名为CaseMatch.java

4.8 匹配重音或复合字符

问题

您希望字符匹配无论以何种形式输入。

解决方案

使用Pattern.CANON_EQ作为规范相等性的flags参数来编译Pattern

讨论

复合字符可以以多种形式输入。例如,考虑带有重音符号的字母e。这个字符可能以多种形式出现在 Unicode 文本中,例如单个字符é(Unicode 字符\u00e9)或两字符序列(e 后跟 Unicode 组合的重音符号\u0301)。为了允许您匹配这些字符,无论使用哪种可能的完全分解形式,正则表达式包中有一个规范匹配选项,它将任何形式视为等效。通过在Pattern.compile()的第二个参数中传递CANON_EQ来启用此选项。此程序展示了使用CANON_EQ来匹配多种形式的示例:

public class CanonEqDemo {
    public static void main(String[] args) {
        String pattStr = "\u00e9gal"; // egal
        String[] input = {
                "\u00e9gal", // egal - this one had better match :-)
                "e\u0301gal", // e + "Combining acute accent"
                "e\u02cagal", // e + "modifier letter acute accent"
                "e'gal", // e + single quote
                "e\u00b4gal", // e + Latin-1 "acute"
        };
        Pattern pattern = Pattern.compile(pattStr, Pattern.CANON_EQ);
        for (int i = 0; i < input.length; i++) {
            if (pattern.matcher(input[i]).matches()) {
                System.out.println(
                    pattStr + " matches input " + input[i]);
            } else {
                System.out.println(
                    pattStr + " does not match input " + input[i]);
            }
        }
    }
}

此程序正确匹配组合重音符号并拒绝其他字符,其中一些不幸地看起来像打印机上的重音符号,但不被视为组合重音符号:

égal matches input égal
égal matches input e?gal
égal does not match input e?gal
égal does not match input e'gal
égal does not match input e´gal

更多详情,请查看字符图表

4.9 匹配文本中的换行符

问题

您需要在文本中匹配换行符。

解决方案

在您的正则表达式模式中使用\n\r。还可以参考标志常量Pattern.MULTILINE,它使换行符作为行首和行尾(\^$)匹配。

讨论

虽然 Unix 中的面向行的工具(如sedgrep)一次只匹配一行的正则表达式,但并非所有工具都是如此。贝尔实验室的sam文本编辑器是我知道的第一个允许多行正则表达式的交互式工具;Perl 脚本语言随后也跟进。在 Java API 中,默认情况下,换行符在正则表达式中没有特殊意义。BufferedReader方法readLine()通常会剥离掉它找到的任何换行符。如果您使用除readLine()之外的某些方法读取大量字符,可能会在文本字符串中有一些\n\r\r\n序列。通常情况下,这些都等同于\n。如果您只想匹配\n,请使用Pattern.compile()方法的UNIX_LINES标志。

在 Unix 中,^$通常用于分别匹配行的开头和结尾。在此 API 中,正则表达式元字符\^$会忽略换行符,并且只在整个字符串的开头和结尾匹配。然而,如果您向Pattern.compile()方法传递MULTILINE标志,这些表达式将在换行符的后面或前面匹配;$也会匹配字符串的最后。因为换行符只是普通字符,您可以用.或类似的表达式来匹配它;如果您想确切地知道它在哪里,模式中的\n\r也可以匹配它。换句话说,对于此 API,换行符只是没有特殊意义的另一个字符。请参见侧边栏“Pattern.compile() Flags”。换行符匹配的示例显示在示例 4-9 中。

示例 4-9. main/src/main/java/regex/NLMatch.java
public class NLMatch {
    public static void main(String[] argv) {

        String input = "I dream of engines\nmore engines, all day long";
        System.out.println("INPUT: " + input);
        System.out.println();

        String[] patt = {
            "engines.more engines",
            "ines\nmore",
            "engines$"
        };

        for (int i = 0; i < patt.length; i++) {
            System.out.println("PATTERN " + patt[i]);

            boolean found;
            Pattern p1l = Pattern.compile(patt[i]);
            found = p1l.matcher(input).find();
            System.out.println("DEFAULT match " + found);

            Pattern pml = Pattern.compile(patt[i],
                Pattern.DOTALL|Pattern.MULTILINE);
            found = pml.matcher(input).find();
            System.out.println("MultiLine match " + found);
            System.out.println();
        }
    }
}

如果您运行此代码,第一个模式(带有通配符字符.)总是匹配,而第二个模式(带有$)仅在设置了MATCH_MULTILINE时匹配:

> java regex.NLMatch
INPUT: I dream of engines
more engines, all day long

PATTERN engines
more engines
DEFAULT match true
MULTILINE match: true

PATTERN engines$
DEFAULT match false
MULTILINE match: true

4.10 程序:解析 Apache 日志文件

Apache Web 服务器是世界上领先的 Web 服务器,几乎在 Web 的整个历史上都是如此。它是世界上最著名的开源项目之一,是 Apache 基金会孵化的第一个项目。Apache 这个名字通常被认为是对服务器起源的一个双关语;其开发人员从自由的 NCSA 服务器开始,并不断地对其进行改进或修补,直到它符合他们的要求。当它与原始版本有了足够的不同之后,就需要一个新的名字。因为它现在是一个“补丁”(patchy)的服务器,所以选择了 Apache 这个名字。官方对这个故事持否定态度,但这个故事非常有趣。实际上显示补丁性的一个地方是日志文件格式。请参考 Example 4-10。

Example 4-10. Apache 日志文件摘录
123.45.67.89 - - [27/Oct/2000:09:27:09 -0400] "GET /java/javaResources.html
HTTP/1.0" 200 10450 "-" "Mozilla/4.6 [en] (X11; U; OpenBSD 2.8 i386; Nav)"

显然,该文件格式设计用于人类检查,但不易于解析。问题在于使用了不同的分隔符:日期用方括号,请求行用引号,整个过程中还散布有空格。考虑尝试使用StringTokenizer;你可能能让它工作,但会花费大量时间摆弄它。实际上,不,你不会让它工作。然而,这个有些扭曲的正则表达式⁵使得解析变得很容易(这是一个超大规模的单个正则表达式;我们不得不将其分成两行以适应书籍的边距):

\^([\d.]+) (\S+) (\S+) \[([\w:/]+\s[+\-]\d{4})\] "(.+?)" (\d{3}) (\d+)
  "([\^"]+)" "([\^"]+)"

如果你回顾一下 Table 4-1,并仔细查看这里使用的完整语法,你可能会觉得很有趣。特别注意在\"(.+?)\"中使用了非贪婪量词+?来匹配引号括起的字符串;你不能仅使用.+,因为那样会匹配太多(直到行尾的引号)。代码用于提取诸如 IP 地址、请求、引用 URL 和浏览器版本等各种字段,在 Example 4-11 中显示。

Example 4-11. main/src/main/java/regex/LogRegExp.java
public class LogRegExp {

    final static String logEntryPattern =
            "^([\\d.]+) (\\S+) (\\S+) \\[([\\w:/]+\\s[+-]\\d{4})\\] " +
            "\"(.+?)\" (\\d{3}) (\\d+) \"([^\"]+)\" \"([^\"]+)\"";

    public static void main(String argv[]) {

        System.out.println("RE Pattern:");
        System.out.println(logEntryPattern);

        System.out.println("Input line is:");
        String logEntryLine = LogParseInfo.LOG_ENTRY_LINE;
        System.out.println(logEntryLine);

        Pattern p = Pattern.compile(logEntryPattern);
        Matcher matcher = p.matcher(logEntryLine);
        if (!matcher.matches() ||
            LogParseInfo.MIN_FIELDS > matcher.groupCount()) {
            System.err.println("Bad log entry (or problem with regex):");
            System.err.println(logEntryLine);
            return;
        }
        System.out.println("IP Address: " + matcher.group(1));
        System.out.println("UserName: " + matcher.group(3));
        System.out.println("Date/Time: " + matcher.group(4));
        System.out.println("Request: " + matcher.group(5));
        System.out.println("Response: " + matcher.group(6));
        System.out.println("Bytes Sent: " + matcher.group(7));
        if (!matcher.group(8).equals("-"))
            System.out.println("Referer: " + matcher.group(8));
        System.out.println("User-Agent: " + matcher.group(9));
    }
}

implements 子句用于仅定义输入字符串的接口;它在演示中被用来比较正则表达式模式与使用StringTokenizer。两个版本的源代码都在本章节的在线资源中。对 Example 4-10 中的示例输入运行程序将得到以下输出:

Using regex Pattern:
\^([\d.]+) (\S+) (\S+) \[([\w:/]+\s[+\-]\d{4})\] "(.+?)" (\d{3}) (\d+) "([\^"]+)"
"([\^"]+)"
Input line is:
123.45.67.89 - - [27/Oct/2000:09:27:09 -0400] "GET /java/javaResources.html
HTTP/1.0" 200 10450 "-" "Mozilla/4.6 [en] (X11; U; OpenBSD 2.8 i386; Nav)"
IP Address: 123.45.67.89
Date&Time: 27/Oct/2000:09:27:09 -0400
Request: GET /java/javaResources.html HTTP/1.0
Response: 200
Bytes Sent: 10450
Browser: Mozilla/4.6 [en] (X11; U; OpenBSD 2.8 i386; Nav)

程序成功地通过一次调用matcher.matches()成功解析了整个日志文件格式条目。

4.11 程序:完整的 Grep

现在我们已经看到正则表达式包如何工作,是时候编写 JGrep 了,这是一个完整的行匹配程序,并包含选项解析。Table 4-2 列出了 Unix 实现中 grep 可能包含的一些典型命令行选项。对于不熟悉 grep 的人来说,它是一个在文本文件中搜索正则表达式的命令行工具。标准 grep 家族中有三到四个程序,还有一个更新的替代 ripgreprg。这个程序是我对这类程序家族的补充。

Table 4-2. Grep 命令行选项

Option 意义
-c 仅计数;不打印行,只计数
-C 上下文;打印与匹配行上下几行(在此版本中未实现;留给读者作为练习)
-f pattern 从名为-f的文件中获取模式,而不是从命令行获取
-h 抑制在行前打印文件名
-i 忽略大小写
-l 仅列出文件名:不打印行,只打印它们所在的文件名
-n 在匹配行之前打印行号
-s 抑制打印某些错误消息
-v 反向:仅打印不匹配模式的行

Unix 世界提供了几个用于解析命令行参数的getopt库例程,因此我在 Java 中重新实现了这一过程。通常情况下,因为main()在静态上下文中运行而我们的应用程序主行没有运行,所以我们可能会传递大量信息到构造函数中。为了节省空间,此版本仅使用全局变量来跟踪从命令行获取的设置。与 Unix 的grep工具不同,这个工具尚不能处理组合选项,因此-l -r -i是可以的,但-lri将失败,这是由于使用的GetOpt解析器的限制。

该程序基本上只是读取行,匹配其中的模式,如果找到匹配(或者使用-v找不到),则打印该行(以及可选的其他一些东西)。话虽如此,代码显示在 Example 4-12 中。

Example 4-12. darwinsys-api/src/main/java/regex/JGrep.java
/** A command-line grep-like program. Accepts some command-line options,
 * and takes a pattern and a list of text files.
 * N.B. The current implementation of GetOpt does not allow combining short
 * arguments, so put spaces e.g., "JGrep -l -r -i pattern file..." is OK, but
 * "JGrep -lri pattern file..." will fail. Getopt will hopefully be fixed soon.
 */
public class JGrep {
    private static final String USAGE =
        "Usage: JGrep pattern [-chilrsnv][-f pattfile][filename...]";
    /** The pattern we're looking for */
    protected Pattern pattern;
    /** The matcher for this pattern */
    protected Matcher matcher;
    private boolean debug;
    /** Are we to only count lines, instead of printing? */
    protected static boolean countOnly = false;
    /** Are we to ignore case? */
    protected static boolean ignoreCase = false;
    /** Are we to suppress printing of filenames? */
    protected static boolean dontPrintFileName = false;
    /** Are we to only list names of files that match? */
    protected static boolean listOnly = false;
    /** Are we to print line numbers? */
    protected static boolean numbered = false;
    /** Are we to be silent about errors? */
    protected static boolean silent = false;
    /** Are we to print only lines that DONT match? */
    protected static boolean inVert = false;
    /** Are we to process arguments recursively if directories? */
    protected static boolean recursive = false;

    /** Construct a Grep object for the pattern, and run it
 * on all input files listed in args.
 * Be aware that a few of the command-line options are not
 * acted upon in this version - left as an exercise for the reader!
 * @param args args
 */
    public static void main(String[] args) {

        if (args.length < 1) {
            System.err.println(USAGE);
            System.exit(1);
        }
        String patt = null;

        GetOpt go = new GetOpt("cf:hilnrRsv");

        char c;
        while ((c = go.getopt(args)) != 0) {
            switch(c) {
                case 'c':
                    countOnly = true;
                    break;
                case 'f':    /* External file contains the pattern */
                    try (BufferedReader b =
                        new BufferedReader(new FileReader(go.optarg()))) {
                        patt = b.readLine();
                    } catch (IOException e) {
                        System.err.println(
                            "Can't read pattern file " + go.optarg());
                        System.exit(1);
                    }
                    break;
                case 'h':
                    dontPrintFileName = true;
                    break;
                case 'i':
                    ignoreCase = true;
                    break;
                case 'l':
                    listOnly = true;
                    break;
                case 'n':
                    numbered = true;
                    break;
                case 'r':
                case 'R':
                    recursive = true;
                    break;
                case 's':
                    silent = true;
                    break;
                case 'v':
                    inVert = true;
                    break;
                case '?':
                    System.err.println("Getopts was not happy!");
                    System.err.println(USAGE);
                    break;
            }
        }

        int ix = go.getOptInd();

        if (patt == null)
            patt = args[ix++];

        JGrep prog = null;
        try {
            prog = new JGrep(patt);
        } catch (PatternSyntaxException ex) {
            System.err.println("RE Syntax error in " + patt);
            return;
        }

        if (args.length == ix) {
            dontPrintFileName = true; // Don't print filenames if stdin
            if (recursive) {
                System.err.println("Warning: recursive search of stdin!");
            }
            prog.process(new InputStreamReader(System.in), null);
        } else {
            if (!dontPrintFileName)
                dontPrintFileName = ix == args.length - 1; // Nor if only one file
            if (recursive)
                dontPrintFileName = false;                // unless a directory!

            for (int i=ix; i<args.length; i++) { // note starting index
                try {
                    prog.process(new File(args[i]));
                } catch(Exception e) {
                    System.err.println(e);
                }
            }
        }
    }

    /**
 * Construct a JGrep object.
 * @param patt The regex to look for
 * @throws PatternSyntaxException if pattern is not a valid regex
 */
    public JGrep(String patt) throws PatternSyntaxException {
        if (debug) {
            System.err.printf("JGrep.JGrep(%s)%n", patt);
        }
        // compile the regular expression
        int caseMode = ignoreCase ?
            Pattern.UNICODE_CASE | Pattern.CASE_INSENSITIVE :
            0;
        pattern = Pattern.compile(patt, caseMode);
        matcher = pattern.matcher("");
    }

    /** Process one command line argument (file or directory)
 * @param file The input File
 * @throws FileNotFoundException If the file doesn't exist
 */
    public void process(File file) throws FileNotFoundException {
        if (!file.exists() || !file.canRead()) {
            throw new FileNotFoundException(
                "Can't read file " + file.getAbsolutePath());
        }
        if (file.isFile()) {
            process(new BufferedReader(new FileReader(file)),
                file.getAbsolutePath());
            return;
        }
        if (file.isDirectory()) {
            if (!recursive) {
                System.err.println(
                    "ERROR: -r not specified but directory given " +
                    file.getAbsolutePath());
                return;
            }
            for (File nf : file.listFiles()) {
                process(nf);    // "Recursion, n.: See Recursion."
            }
            return;
        }
        System.err.println(
            "WEIRDNESS: neither file nor directory: " + file.getAbsolutePath());
    }

    /** Do the work of scanning one file
 * @param    ifile    Reader    Reader object already open
 * @param    fileName String    Name of the input file
 */
    public void process(Reader ifile, String fileName) {

        String inputLine;
        int matches = 0;

        try (BufferedReader reader = new BufferedReader(ifile)) {

            while ((inputLine = reader.readLine()) != null) {
                matcher.reset(inputLine);
                if (matcher.find()) {
                    if (listOnly) {
                        // -l, print filename on first match, and we're done
                        System.out.println(fileName);
                        return;
                    }
                    if (countOnly) {
                        matches++;
                    } else {
                        if (!dontPrintFileName) {
                            System.out.print(fileName + ": ");
                        }
                        System.out.println(inputLine);
                    }
                } else if (inVert) {
                    System.out.println(inputLine);
                }
            }
            if (countOnly)
                System.out.println(matches + " matches in " + fileName);
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

¹ 非 Unix 粉丝不必担心,在 Windows 系统上可以使用诸如grep之类的工具,使用几种不同的软件包。其中一个是开源软件包,交替称为 CygWin(源自 Cygnus Software)或GnuWin32。另一个是微软的findstr命令。或者,如果你的系统上没有grep,可以使用我在 Recipe 4.6 中展示的Grep程序。顺便说一句,grep的名字来自于古老的 Unix 行编辑器命令g/RE/p,即在编辑缓冲区中全局查找正则表达式并打印匹配行的命令——这正是grep程序对文件中的行所做的事情。

² REDemo 受到(但未使用任何代码)现已退役的 Apache Jakarta 正则表达式包中提供的类似程序的启发。

³ 在 Unix 上,Shell 或命令行解释器在运行程序之前会将**.txt*扩展到所有匹配的文件名,但在没有足够活跃或聪明的 Shell 系统上,正常的 Java 解释器会为你完成这些工作。

⁴ 或者一些相关的 Unicode 字符,包括下一行(\u0085)、行分隔符(\u2028)和段落分隔符(\u2029)字符。

⁵ 你可能会认为这在正则表达式比赛中是某种复杂性世界纪录,但我相信它已经被超越了许多次。

第五章:数字

5.0 简介

数字是几乎所有计算的基础。它们用于数组索引、温度、薪水、评分以及各种各样的事情。然而,它们并不像它们看起来那么简单。对于浮点数,精度有多精确?对于随机数,随机有多随机?对于应该包含数字的字符串,什么才算是数字?

Java 有几种内置的或 原始 类型可用于表示数字,总结在 Table 5-1 中,以及它们的 包装(对象)类型,以及一些不表示原始类型的数值类型。请注意,与诸如 C 或 Perl 等不指定数值类型的大小或精度的语言不同,Java —— 其目标是可移植性 —— 精确地指定了这些,并声明它们在所有平台上都是相同的。

表 5-1。数值类型

内置类型 对象包装器 内置大小(位) 内容
byte Byte 8 有符号整数
short Short 16 有符号整数
int Integer 32 有符号整数
long Long 64 有符号整数
float Float 32 IEEE-754 浮点数
double Double 64 IEEE-754 浮点数
char Character 16 无符号 Unicode 字符
n/a BigInteger 无限制 任意大小的不可变整数值
n/a BigDecimal 无限制 任意大小和精度的不可变浮点值

如你所见,Java 提供了几乎任何目的的数字类型。有四种大小的有符号整数,用于表示各种大小的整数。有两种大小的浮点数来近似实数。还有一种类型专门设计用于表示和允许对 Unicode 字符进行操作。这里讨论了原始数值类型。大数值类型在 Recipe 5.12 中描述。

当你从用户输入或文本文件中读取表示数字的字符串时,你需要将其转换为适当的类型。第二列中的对象包装类有几个函数,其中之一是提供此基本转换功能的—替换 C 程序员的 atoi/atof 函数系列和 scanf 的数值参数。

另一种方法是,你可以通过使用字符串连接来将任何数字(事实上,Java 中的任何东西)转换为字符串。如果你想对数字格式进行一点控制,Recipe 5.5 展示了如何使用一些对象包装器的转换例程。如果你想要完全控制,该配方还展示了使用 NumberFormat 及其相关类来提供完全控制格式的方法。

正如 对象包装 这个名字所暗示的,这些类也用于在 Java 对象中包装一个数字,因为标准 API 的许多部分都是以对象的形式定义的。稍后的 “解决方案” 展示了如何使用 Integer 对象将 int 的值保存到文件中,并稍后检索该值。

但我还没有提到浮点数的问题。实数,你可能记得,是带有小数部分的数字。实数有无限多个。计算机用来近似实数的浮点数并非与实数相同。浮点数的数量是有限的,float 有 2³² 个不同的位模式,double 有 2⁶⁴ 个。因此,大多数实数值与浮点数只有近似对应关系。打印实数 0.3 的结果是正确的,如下所示:

// numbers/RealValues.java
System.out.println("The real value 0.3 is " + 0.3);

该代码的输出是:

The real value 0.3 is 0.3

但是,如果将值用于计算,实际值与其浮点数近似值之间的差异可能会累积;这通常称为 舍入误差。继续前面的例子,实数 0.3 乘以 3 的结果是:

The real 0.3 times 3 is 0.89999999999999991

惊讶吗?它不仅比你预期的偏了一点,而且在任何符合 Java 实现上都会得到相同的输出。我在不同的机器上运行过它,如 AMD/Intel PC 上的 OpenBSD、带有标准 JDK 的 Windows PC 和 macOS 上。始终得到相同的答案。

随机数又如何呢?它们有多随机?你可能听说过 伪随机数生成器 或 PRNG 这个术语。所有传统的随机数生成器,无论是用 Fortran、C 还是 Java 编写的,都生成伪随机数。也就是说,它们并非真正随机!真正的随机性只能来自专门构建的硬件:例如连接到模拟至数字转换器的布朗噪声的模拟源。¹ 如今的普通 PC 可能具有一些良好的熵源,甚至是硬件基础的随机源(尚未广泛使用或测试)。然而,对于大多数目的,伪随机数生成器已经足够好,因此我们使用它们。Java 在基础库 java.lang.Math 中提供了一个随机生成器和其他几个;我们将在 食谱 5.9 中详细讨论这些。

java.lang.Math 包含一个完整的数学库,包括三角函数、转换(包括度数到弧度和反向转换)、四舍五入、截断、平方根、最小值和最大值。所有这些功能都在这个类中。查看 java.lang.Math 的 javadoc 获取更多信息。

java.math 包含对 大数 的支持 —— 即大于普通内置长整数的数字。参见 食谱 5.12。

Java 通过确保程序的可靠性而闻名。您通常会在 Java API 中注意到这一点,常见的体现是需要捕获潜在异常,并在尝试存储可能不适合的值时进行 强制转换 或转换。我将展示这些的示例。

总体而言,Java 对数值数据的处理非常符合可移植性、可靠性和编程便利性的理念。

参见

Java 语言规范,以及 java.lang.Math 的 javadoc 页面。

5.1 检查字符串是否为有效数字

问题

您需要检查给定的字符串是否包含有效的数字,如果是,则将其转换为二进制(内部)形式。

解决方案

要实现此目标,使用适当的包装类转换程序,并捕获 NumberFormatException。以下代码将字符串转换为 double

    public static void main(String[] argv) {
        String aNumber = argv[0];    // not argv[1]
        double result;
        try {
            result = Double.parseDouble(aNumber);
            System.out.println("Number is " + result);
        } catch(NumberFormatException exc) {
            System.out.println("Invalid number " + aNumber);
            return;
        }
    }

讨论

此代码允许您仅验证符合包装类设计者期望格式的数字。如果需要接受不同定义的数字,可以使用正则表达式(参见 第四章)进行判断。

有时您可能想知道给定的数字是整数还是浮点数。一种方法是检查输入中是否包含 .、def 字符;如果存在其中一个字符,则将数字转换为 double。否则,将其作为 int 转换:

    /*
 * Process one String, returning it as a Number subclass
 */
    public static Number process(String s) {
        if (s.matches("[+-]*\\d*\\.\\d+[dDeEfF]*")) {
            try {
                double dValue = Double.parseDouble(s);
                System.out.println("It's a double: " + dValue);
                return Double.valueOf(dValue);
            } catch (NumberFormatException e) {
                System.out.println("Invalid double: " + s);
                return Double.NaN;
            }
        } else // did not contain . d e or f, so try as int.
            try {
                int iValue = Integer.parseInt(s);
                System.out.println("It's an int: " + iValue);
                return Integer.valueOf(iValue);
            } catch (NumberFormatException e2) {
                System.out.println("Not a number: " + s);
                return Double.NaN;
            }
    }

参见

DecimalFormat 类提供了更复杂的解析形式,详见 Recipe 5.5。

Scanner 类也存在;参见 Recipe 10.6。

5.2 将数字转换为对象和反之亦然

问题

您需要将数字转换为对象,以及对象转换为数字。

解决方案

使用本章开头列出的对象包装类,请参见 表格 5-1。

讨论

您经常有一个原始数字,需要将其传递给需要 Object 的方法,或者反之亦然。很久以前,您必须调用包装类的转换程序,但现在通常可以使用自动转换(称为 自动装箱/自动拆箱)。参见 示例 5-1 中的示例。

示例 5-1. main/src/main/java/structure/AutoboxDemo.java
public class AutoboxDemo {

    /** Shows auto-boxing (in the call to foo(i), i is wrapped automatically)
     * and auto-unboxing (the return value is automatically unwrapped).
     */
    public static void main(String[] args) {
        int i = 42;
        int result = foo(i);            ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        System.out.println(result);
    }

    public static Integer foo(Integer i) {
        System.out.println("Object = " + i);
        return Integer.valueOf(123);    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
    }
}

1

自动装箱:int 42 被转换为 Integer(42)。还有自动拆箱:从 foo() 返回的 Integer 被自动拆箱以赋值给 int result

2

没有自动装箱:valueOf() 返回 Integer。如果行中写成 return Integer.intValueOf(123),那么这将是自动装箱的第二个示例,因为方法返回值是 Integer

要显式地在 intInteger 对象之间进行转换,或者反之亦然,可以使用包装类的方法:

public class IntObject {
    public static void main(String[] args) {
        // int to Integer
        Integer i1 = Integer.valueOf(42);
        System.out.println(i1.toString());        // or just i1

        // Integer to int
        int i2 = i1.intValue();
        System.out.println(i2);
    }
}

5.3 在不使用浮点数的情况下取整数的分数

问题

您希望将整数乘以分数而不将分数转换为浮点数。

解决方案

将整数乘以分子然后除以分母。

仅当效率比清晰更重要时,才应使用此技术,因为它倾向于减弱代码的可读性—因此也降低了代码的可维护性。

讨论

因为整数和浮点数的存储方式不同,有时为了效率的目的,可能希望以整数乘以分数值而不将值转换为浮点数并返回,也不需要强制转换:

public class FractMult {
    public static void main(String[] u) {

        double d1 = 0.666 * 5;  // fast but obscure and inaccurate: convert
        System.out.println(d1); // 2/3 to 0.666 in programmer's head

        double d2 = 2/3 * 5;    // wrong answer - 2/3 == 0, 0*5 = 0
        System.out.println(d2);

        double d3 = 2d/3d * 5;  // "normal"
        System.out.println(d3);

        double d4 = (2*5)/3d;   // one step done as integers, almost same answer
        System.out.println(d4);

        int i5 = 2*5/3;         // fast, approximate integer answer
        System.out.println(i5);
    }
}

运行代码如下:

$ java numbers.FractMult
3.33
0.0
3.333333333333333
3.3333333333333335
3
$

如果不能保证通过分子的乘法不会溢出,应该注意可能发生的数值溢出,并避免此优化。

5.4 使用浮点数

问题

您希望能够比较和四舍五入浮点数。

解决方案

INFINITY 常量进行比较,并使用 isNaN() 检查是否为 NaN(非数值)。

用一个 epsilon 值比较浮点数值。

Math.round() 或自定义代码四舍五入浮点数值。

讨论

比较可能有些棘手:固定点操作可以做诸如除以零的事情,导致 Java 通过抛出异常来突然通知您。这是因为整数除以零被视为逻辑错误

然而,浮点操作不会抛出异常,因为它们在(几乎)无限的值范围内定义。相反,如果您将正浮点数除以零,则会生成常量 POSITIVE_INFINITY;如果将负浮点值除以零,则会生成常量 NEGATIVE_INFINITY;如果以其他方式生成无效结果,则会生成 NaN。这三个公共常量的值在 FloatDouble 包装类中都有定义。值 NaN 具有不同寻常的属性,即它不等于自身(即 NaN != NaN)。因此,将一个(可能可疑的)数字与 NaN 进行比较几乎没有意义,因为以下表达式永远不会为真:

x == NaN

而应使用方法 Float.isNaN(float)Double.isNaN(double)

    public static void main(String[] argv) {
        double d = 123;
        double e = 0;
        if (d/e == Double.POSITIVE_INFINITY)
            System.out.println("Check for POSITIVE_INFINITY works");
        double s = Math.sqrt(-1);
        if (s == Double.NaN)
            System.out.println("Comparison with NaN incorrectly returns true");
        if (Double.isNaN(s))
            System.out.println("Double.isNaN() correctly returns true");
    }

请注意,仅此本身并不足以确保浮点数计算具有足够的精度。例如,以下程序演示了一种构造的计算——海伦公式用于三角形的面积——分别使用 floatdouble。双精度值是正确的,但由于舍入误差,浮点值为零。这是因为在 Java 中,仅涉及 float 值的操作是以 32 位计算的。相关的语言如 C 在计算过程中会自动将这些值提升为 double,从而可以消除一些精度损失。让我们来看一下:

public class Heron {
    public static void main(String[] args) {
        // Sides for triangle in float
        float af, bf, cf;
        float sf, areaf;

        // Ditto in double
        double ad, bd, cd;
        double sd, aread;

        // Area of triangle in float
        af = 12345679.0f;
        bf = 12345678.0f;
        cf = 1.01233995f;

        sf = (af+bf+cf)/2.0f;
        areaf = (float)Math.sqrt(sf * (sf - af) * (sf - bf) * (sf - cf));
        System.out.println("Single precision: " + areaf);

        // Area of triangle in double
        ad = 12345679.0;
        bd = 12345678.0;
        cd = 1.01233995;

        sd = (ad+bd+cd)/2.0d;
        aread = Math.sqrt(sd * (sd - ad) * (sd - bd) * (sd - cd));
        System.out.println("Double precision: " + aread);
    }
}

现在让我们运行它:

$ java numbers.Heron
Single precision: 0.0
Double precision: 972730.0557076167

如果有疑问,使用 double

为了确保在不同的 Java 实现上对非常大幅度的双精度计算保持一致,Java 提供了关键字 strictfp,它可以应用于类、接口或类中的方法。² 如果计算是 Strict-FP,那么如果计算会使 Double.MAX_VALUE 的值溢出(或者使值 Double.MIN_VALUE 的值下溢),它必须始终返回值 INFINITY。非 Strict-FP 计算(默认情况下)允许在更大范围内执行计算,并且可以返回在范围内的有效最终结果,即使中间产品超出范围。这非常神秘,仅影响接近双精度范围的计算。

比较浮点值

根据我们刚刚讨论的内容,你可能不会只是简单地比较两个浮点数或双精度数的相等性。你可能期望浮点包装类 FloatDouble 重写 equals() 方法,它们确实如此。equals() 方法在这两个值在位上完全相同时返回 true(即这两个数相同或都是 NaN)。否则返回 false,包括传入的参数为 null,或者一个对象是 +0.0 而另一个是 -0.0。

我之前说过 NaN != NaN,但是如果使用 equals() 进行比较,结果是 true:

jshell> Float f1 = Float.valueOf(Float.NaN)
f1 ==> NaN

jshell> Float f2 = Float.valueOf(Float.NaN)
f2 ==> NaN

jshell> f1 == f2 # Comparing object identities
$4 ==> false

jshell> f1.equals(f1) # bitwise comparison of values
$5 ==> true

如果这听起来有些奇怪,请记住,这种复杂性部分来自于在较不精确的浮点硬件中进行实数计算的性质。它也部分来自于 IEEE 标准 754 的细节,该标准指定了 Java 尝试遵循的浮点功能,以便即使在解释 Java 程序时也可以使用底层浮点处理器硬件。

要实际比较浮点数是否相等,通常希望在某个允许误差范围内进行比较;这个范围通常被称为容差或epsilon。示例 5-2 展示了一个你可以用来进行这种比较的 equals() 方法,以及对 NaN 值的比较。运行时,它打印出前两个数字在 epsilon 范围内相等:

$ java numbers.FloatCmp
True within epsilon 1.0E-7
$
示例 5-2. main/src/main/java/numbers/FloatCmp.java
public class FloatCmp {

    final static double EPSILON = 0.0000001;

    public static void main(String[] argv) {
        double da = 3 * .3333333333;
        double db = 0.99999992857;

        // Compare two numbers that are expected to be close.
        if (da == db) {
            System.out.println("Java considers " + da + "==" + db);
        // else compare with our own equals overload
        } else if (equals(da, db, 0.0000001)) {
            System.out.println("Equal within epsilon " + EPSILON);
        } else {
            System.out.println(da + " != " + db);
        }

        System.out.println("NaN prints as " + Double.NaN);

        // Show that comparing two NaNs is not a good idea:
        double nan1 = Double.NaN;
        double nan2 = Double.NaN;
        if (nan1 == nan2)
            System.out.println("Comparing two NaNs incorrectly returns true.");
        else
            System.out.println("Comparing two NaNs correctly reports false.");

        if (Double.valueOf(nan1).equals(Double.valueOf(nan2)))
            System.out.println("Double(NaN).equals(NaN) correctly returns true.");
        else
            System.out.println(
                "Double(NaN).equals(NaN) incorrectly returns false.");
    }

    /** Compare two doubles within a given epsilon */
    public static boolean equals(double a, double b, double eps) {
        if (a==b) return true;
        // If the difference is less than epsilon, treat as equal.
        return Math.abs(a - b) < eps;
    }

    /** Compare two doubles, using default epsilon */
    public static boolean equals(double a, double b) {
        return equals(a, b, EPSILON);
    }
}

注意,关于不正确返回的 System.err 消息,没有任何打印。这个带有 NaN 的例子的重点在于,在将值委托给 Double.equals() 之前,你应该始终确保这些值不是 NaN

舍入

如果你简单地将浮点值强制转换为整数值,Java 会截断该值。像 3.999999 这样的值,被转换为 intlong 就变成了 3,而不是 4。要正确地四舍五入浮点数,请使用 Math.round()。它有两个重载:如果给它一个 double,你会得到一个 long 结果;如果给它一个 float,你会得到一个 int

如果您不喜欢round使用的舍入规则怎么办?如果由于某种奇怪的原因,您想要将大于 0.54 的数字四舍五入而不是正常的 0.5,您可以编写自己版本的round()

public class Round {
    /** We round a number up if its fraction exceeds this threshold. */
    public static final double THRESHOLD = 0.54;

    /*
 * Round floating values to integers.
 * @return the closest int to the argument.
 * @param d A non-negative values to be rounded.
 */
    public static int round(double d) {
        return (int)Math.floor(d + 1.0 - THRESHOLD);
    }

    public static void main(String[] argv) {
        for (double d = 0.1; d<=1.0; d+=0.05) {
            System.out.println("My way:  " + d + "-> " + round(d));
            System.out.println("Math way:" + d + "-> " + Math.round(d));
        }
    }
}

另一方面,如果您只想显示一个比它通常更少精确度的数字,您可能希望使用一个DecimalFormat对象或一个Formatter对象,我们在 Recipe 5.5 中看看它。

5.5 格式化数字

问题

您需要格式化数字。

解决方案

使用NumberFormat子类。

最初 Java 并没有提供类似 C 语言的printf/scanf函数,因为它们往往以一种非常不灵活的方式混合了格式化和输入/输出。例如,使用printf/scanf的程序很难国际化。当然,由于广泛需求,Java 最终引入了printf(),现在和String.format()一起成为 Java 的标准;参见 Recipe 10.4。

Java 有一个完整的包java.text,提供了像您可以想象的任何一样通用和灵活的格式化例程。与printf类似,它有一个复杂的格式化语言,在 javadoc 页面中有描述。考虑长数字的呈现方式。在北美,一千零二十四点二五写作 1,024.25;在大多数欧洲地区,写作 1 024,25;而在世界其他地方,可能写作 1.024,25。更不用说货币和百分比的格式化了!试图自己跟踪这些将会迅速让普通的小软件店崩溃。

幸运的是,java.text包包括一个Locale类;此外,Java 运行时根据用户的环境自动设置默认的Locale对象(在 Macintosh 和 Windows 上是用户的偏好设置,在 Unix 上是用户的环境变量)。要在代码中提供非默认的语言环境,请参见 Recipe 3.12。为了提供针对数字、货币和百分比定制的格式化程序,NumberFormat类具有静态的工厂方法,通常返回一个已经实例化了正确模式的DecimalFormat。可以从工厂方法NumberFormat.getInstance()获取适合用户区域设置的DecimalFormat对象,并使用set方法进行操作。令人惊讶的是,方法setMinimumIntegerDigits()竟然是生成带有前导零的数字格式的简便方法。以下是一个例子:

public class NumFormat2 {
    /** A number to format */
    public static final double data[] = {
        0, 1, 22d/7, 100.2345678
    };

    /** The main (and only) method in this class. */
    public static void main(String[] av) {
        // Get a format instance
        NumberFormat form = NumberFormat.getInstance();

        // Set it to look like 999.99[99]
        form.setMinimumIntegerDigits(3);
        form.setMinimumFractionDigits(2);
        form.setMaximumFractionDigits(4);

        // Now print using it
        for (int i=0; i<data.length; i++)
            System.out.println(data[i] + "\tformats as " +
                form.format(data[i]));
    }
}

这段代码使用NumberFormat实例form打印数组内容:

$ java numbers.NumFormat2
0.0     formats as 000.00
1.0     formats as 001.00
3.142857142857143       formats as 003.1429
100.2345678     formats as 100.2346
$

您还可以使用特定模式构造或使用applyPattern()动态更改DecimalFormat。一些更常见的模式字符在 Table 5-2 中显示。

表 5-2. DecimalFormat 模式字符

字符 含义
# 数字(不包含前导零)
0 数字(包含前导零)
. 区域特定的十进制分隔符(小数点)
, 区域特定的分组分隔符(英文逗号)
- 区域特定的负数指示符(减号)
% 将值显示为百分比
; 分隔两种格式:第一种是正数,第二种是负数
' 转义上述字符中的一个以使其显示
其他任何字符 仍然显示为它本身

NumFormatDemo程序使用一个DecimalFormat来仅打印带有两位小数的数字,并使用第二个根据默认区域设置格式化数字:

    /** A number to format */
    public static final double intlNumber = 1024.25;
    /** Another number to format */
    public static final double ourNumber = 100.2345678;
        NumberFormat defForm = NumberFormat.getInstance();
        NumberFormat ourForm = new DecimalFormat("##0.##");
        // toPattern() will reveal the combination of #0., etc
        // that this particular Locale uses to format with!
        System.out.println("defForm's pattern is " +
            ((DecimalFormat)defForm).toPattern());
        System.out.println(intlNumber + " formats as " +
            defForm.format(intlNumber));
        System.out.println(ourNumber + " formats as " +
            ourForm.format(ourNumber));
        System.out.println(ourNumber + " formats as " +
            defForm.format(ourNumber) + " using the default format");

此程序打印给定的模式,然后使用几种格式化方法格式化同一个数字:

$ java numbers.NumFormatDemo
defForm's pattern is #,##0.###
1024.25 formats as 1,024.25
100.2345678 formats as 100.23
100.2345678 formats as 100.235 using the default format
$

人类可读的数字格式化

要以 Linux/Unix 中称为“人类可读格式”打印数字(许多显示命令接受a -h参数以此格式),使用 Java 12 的CompactNumberFormat,如示例 5-3 中所示。

示例 5-3. nmain/src/main/java/numbers/CompactFormatDemo.java
public class CompactFormatDemo {

    static final Number[] nums = {
        0, 1, 1.25, 1234, 12345, 123456.78, 123456789012L
    };
    static final String[] strs = {
        "1", "1.25", "1234", "12.345K", "1234556.78", "123456789012L"
    };

    public static void main(String[] args) throws ParseException {
        NumberFormat cnf = NumberFormat.getCompactNumberInstance();
        System.out.println("Formatting:");
        for (Number n : nums) {
            cnf.setParseIntegerOnly(false);
            cnf.setMinimumFractionDigits(2);
            System.out.println(n + ": " + cnf.format(n));
        }
        System.out.println("Parsing:");
        for (String s : strs) {
            System.out.println(s + ": " + cnf.parse(s));
        }
    }

}

罗马数字格式化

要处理罗马数字,使用我的RomanNumberFormat类,如此演示:

        RomanNumberFormat nf = new RomanNumberFormat();
        int year = LocalDate.now().getYear();
        System.out.println(year + " -> " + nf.format(year));

在 2020 年运行RomanNumberSimple会产生以下输出:

2020->MMXX

RomanNumberFormat类的源代码位于src/main/java/numbers/RomanNumberFormat.java。多个公共方法是必需的,因为我希望它是Format的子类,而Format是抽象的。这就解释了一些复杂性,比如有三种不同的格式化方法。

注意,RomanNumberFormat.parseObject( )方法也是必需的,但此版本的代码不实现解析。

参见

Java I/O 由 Elliotte Harold(O’Reilly)包含了一个关于NumberFormat的整章内容,并开发了ExponentialNumberFormat的子类。

5.6 将二进制、八进制、十进制和十六进制相互转换

问题

当你想要以一系列位的形式显示整数时——例如与某些硬件设备交互时——或以其他数制(二进制是基数 2,八进制是基数 8,十进制是 10,十六进制是 16)显示整数时,你想要将二进制数或十六进制值转换为整数。

解决方案

java.lang.Integer类提供了解决方案。大多数情况下,您可以使用Integer.parseInt(String input, int radix)将任何类型的数字转换为Integer,并使用Integer.toString(int input, int radix)完成反向操作。示例 5-4 展示了一些使用Integer类的示例。

示例 5-4. main/src/main/java/numbers/IntegerBinOctHexEtc.java
        String input = "101010";
        for (int radix : new int[] { 2, 8, 10, 16, 36 }) {
            System.out.print(input + " in base " + radix + " is "
                    + Integer.valueOf(input, radix) + "; ");
            int i = 42;
            System.out.println(i + " formatted in base " + radix + " is "
                    + Integer.toString(i, radix));
        }

此程序将二进制字符串打印为各种数制中的整数,并将整数 42 在相同的数制中显示:

$ java numbers.IntegerBinOctHexEtc
101010 in base 2 is 42; 42 formatted in base 2 is 101010
101010 in base 8 is 33288; 42 formatted in base 8 is 52
101010 in base 10 is 101010; 42 formatted in base 10 is 42
101010 in base 16 is 1052688; 42 formatted in base 16 is 2a
101010 in base 36 is 60512868; 42 formatted in base 36 is 16
$ 

讨论

也有专门的toString(int)版本,不需要指定基数,例如,toBinaryString()将整数转换为二进制,toHexString()转换为十六进制,toOctalString()等等。Integer类的 Javadoc 页面是你的好帮手。

String类本身包含一系列静态方法——valueOf(int)valueOf(double)等等,它们还提供默认格式化。也就是说,它们将给定的数值格式化为字符串并返回。

5.7 操作整数序列

问题

你需要处理一系列整数。

解决方案

对于连续的集合,请使用IntStream::rangerangeClosed,或者旧的for循环。

对于不连续的数字范围,使用java.util.BitSet

讨论

为了处理连续的整数集合,Java 提供了IntStreamLongStream类中的range() / rangeClosed()方法。它们接受起始和结束数字;range()排除结束数字,而rangeClosed()包含结束数字。你还可以使用传统的for循环迭代一系列数字。for循环的循环控制有三个部分:初始化、测试和更改。如果测试部分最初为 false,则循环永远不会执行,即使一次也不会执行。你可以使用 for-each 循环来迭代数组或集合的元素(参见第七章)。

示例 5-5 中的程序演示了这些技术。

示例 5-5. main/src/main/java/numbers/NumSeries.java
public class NumSeries {
    public static void main(String[] args) {

        // For ordinal list of numbers n to m, use rangeClosed(start, endInclusive)
        IntStream.rangeClosed(1, 12).forEach(
            i -> System.out.println("Month # " + i));

        // Or, use a for loop starting at 1.
        for (int i = 1; i <= months.length; i++)
            System.out.println("Month # " + i);

        // Or a foreach loop
        for (String month : months) {
            System.out.println(month);
        }

        // When you want a set of array indices, use range(start, endExclusive)
        IntStream.range(0, months.length).forEach(
            i -> System.out.println("Month " + months[i]));

        // Or, use a for loop starting at 0.
        for (int i = 0; i < months.length; i++)
            System.out.println("Month " + months[i]);

        // For e.g., counting by 3 from 11 to 27, use a for loop
        for (int i = 11; i <= 27; i += 3) {
            System.out.println("i = " + i);
        }

        // A discontiguous set of integers, using a BitSet

        // Create a BitSet and turn on a couple of bits.
        BitSet b = new BitSet();
        b.set(0);    // January
        b.set(3);    // April
        b.set(8);    // September

        // Presumably this would be somewhere else in the code.
        for (int i = 0; i<months.length; i++) {
            if (b.get(i))
                System.out.println("Month " + months[i]);
        }

        // Same example but shorter:
        // a discontiguous set of integers, using an array
        int[] numbers = {0, 3, 8};

        // Presumably somewhere else in the code... Also a foreach loop
        for (int n : numbers) {
            System.out.println("Month: " + months[n]);
        }
    }
    /** Names of months. See Dates/Times chapter for a better way to get these */
    protected static String months[] = {
        "January", "February", "March", "April",
        "May", "June", "July", "August",
        "September", "October", "November", "December"
    };
}

5.8 使用正确的复数格式化

问题

你正在打印类似于"We used " + n + " items"的内容,但在英语中,“We used 1 items”是不符合语法的。你想要的是“We used 1 item。”

解决方案

使用ChoiceFormat或条件语句。

在字符串连接中使用 Java 的三元运算符(cond ? trueval : falseval)。在英语中,零和复数的名词都会附加“s”(“no books, one book, two books”),因此我们测试n==1

public class FormatPlurals {
    public static void main(String[] argv) {
        report(0);
        report(1);
        report(2);
    }

    /** report -- using conditional operator */
    public static void report(int n) {
        System.out.println("We used " + n + " item" + (n==1?"":"s"));
    }
}

它有效吗?

$ java numbers.FormatPlurals
We used 0 items
We used 1 item
We used 2 items
$

最终的println语句与以下内容实际上等效:

if (n==1)
    System.out.println("We used " + n + " item");
else
    System.out.println("We used " + n + " items");

这样写会更长,所以学会使用三元条件运算符是值得的。

对于这个问题,ChoiceFormat非常理想。实际上,它能做的远不止这些,但我只展示最简单的用法。我指定了值 0、1 和 2(或更多)以及对应于每个数字的要打印的字符串值。然后根据它们所属的范围来格式化数字:

public class FormatPluralsChoice extends FormatPlurals {

    // ChoiceFormat to just give pluralized word
    static double[] limits = { 0, 1, 2 };
    static String[] formats = { "reviews", "review", "reviews"};
    static ChoiceFormat pluralizedFormat = new ChoiceFormat(limits, formats);

    // ChoiceFormat to give English text version, quantified
    static ChoiceFormat quantizedFormat = new ChoiceFormat(
        "0#no reviews|1#one review|1<many reviews");

    // Test data
    static int[] data = { -1, 0, 1, 2, 3 };

    public static void main(String[] argv) {
        System.out.println("Pluralized Format");
        for (int i : data) {
            System.out.println("Found " + i + " " + pluralizedFormat.format(i));
        }

        System.out.println("Quantized Format");
        for (int i : data) {
            System.out.println("Found " + quantizedFormat.format(i));
        }
    }
}

这与基本版本生成的输出相同。它略长一些,但更通用,更适合国际化。

另请参阅

除了使用ChoiceFormat,还可以通过MessageFormat达到相同的效果。文件main/src/main/java/i18n/MessageFormatDemo.java中有一个示例。

5.9 生成随机数

问题

你需要快速生成伪随机数。

解决方案

使用java.lang.Math.random()来生成随机数。不能保证它返回的随机值非常,然而。像大多数仅软件实现一样,这些都是伪随机数生成器(PRNGs),意味着这些数字不是完全随机的,而是根据算法设计的。尽管如此,它们对于日常使用是足够的。这段代码演示了random()方法:

// numbers/Random1.java
// java.lang.Math.random( ) is static, don't need any constructor calls
System.out.println("A random from java.lang.Math is " + Math.random( ));

注意这种方法只生成双精度浮点数。如果需要整数,请构造一个java.util.Random对象并调用其nextInt()方法;如果传递整数值,这将成为上限。这里我生成了从 1 到 10 的整数:

public class RandomInt {
    public static void main(String[] a) {
        Random r = new Random();
        for (int i=0; i<1000; i++)
            // nextInt(10) goes from 0-9; add 1 for 1-10;
            System.out.println(1+r.nextInt(10));
    }
}

要查看我的RandomInt演示是否真的运行良好,我使用了 Unix 工具sortuniq,它们一起给出每个值被选择多少次的计数。对于 1,000 个整数,每个值应该被选择大约 100 次。我运行了两次以更好地了解分布情况:

$ java numbers.RandomInt | sort | uniq -c | sort -k 2 -n
  96 1
 107 2
 102 3
 122 4
  99 5
 105 6
  97 7
  96 8
  79 9
  97 10
$ java -cp build numbers.RandomInt | sort | uniq -c | sort -k 2 -n
  86 1
  88 2
 110 3
  97 4
  99 5
 109 6
  82 7
 116 8
  99 9
 114 10
$

下一步是通过统计程序运行这些数据,看看它们真的有多随机;我们将在一分钟内返回这个问题。

通常,要生成随机数,您需要构造一个java.util.Random对象(不只是任意的随机对象)并调用其next*()方法。这些方法包括nextBoolean()nextBytes()(它用随机值填充给定的字节数组)、nextDouble()nextFloat()nextInt()nextLong()。不要被FloatDouble等的大写所迷惑。它们返回基本类型booleanfloatdouble等,而不是大写的包装对象。清楚了吗?也许一个例子会有所帮助:

    // java.util.Random methods are non-static, so need to construct
    Random r = new Random();
    for (int i=0; i<10; i++)
    System.out.println("A double from java.util.Random is " + r.nextDouble());
    for (int i=0; i<10; i++)
    System.out.println("An integer from java.util.Random is " + r.nextInt());

可以提供一个固定值(起始种子)以生成可重复的值,例如用于测试。您还可以使用java.util.Random nextGaussian()方法,如下所示。nextDouble()方法试图在 0 到 1.0 之间提供一个平坦的分布,其中每个值被选择的机会相等。高斯或正态分布是一个从负无穷到正无穷的钟形曲线,大多数值围绕着零(0.0)。

// numbers/Random3.java
Random r = new Random();
for (int i = 0; i < 10; i++)
    System.out.println("A gaussian random double is " + r.nextGaussian());

为了说明不同的分布,我首先使用nextRandom()生成了 10,000 个数字,然后使用nextGaussian()。这个代码在Random4.java中(这里未显示),是前几个程序的组合,并包含将结果打印到文件的代码。然后使用 R 统计包绘制了直方图(参见第十一章和http://www.r-project.org)。用于生成图表的 R 脚本randomnesshistograms.r位于javasrc下的main/src/main/resources中。结果显示在图 5-1 中。

看起来两个 PRNG 都在做它们的工作!

jcb4 0501

图 5-1。平坦(左)和高斯(右)分布

参见

java.util.Random的 javadoc 文档,以及 Recipe 5.0 中关于伪随机性与真随机性的警告。

对于加密用途,请参阅java.security.SecureRandom类,它提供了具有密码强度的伪随机数生成器。

5.10 矩阵相乘

问题

您需要计算一对二维数组的乘积,这在数学和工程应用中很常见。

解决方案

使用以下代码作为模型。

讨论

在数值类型的数组中进行乘法运算是很直接的。在实际应用中,您可能会使用完整的包,比如Efficient Java Matrix Library (EJML)或 DeepLearning4Java 的ND4J package。然而,一个简单的实现可以展示所涉及的概念;示例 5-6 中的代码实现了矩阵乘法。

示例 5-6. Matrix.java
public class Matrix {

    /* Matrix-multiply two arrays together.
 * The arrays MUST be rectangular.
 * @author Adapted from Tom Christiansen & Nathan Torkington's
 * implementation in their Perl Cookbook.
 */
    public static int[][] multiply(int[][] m1, int[][] m2) {
        int m1rows = m1.length;
        int m1cols = m1[0].length;
        int m2rows = m2.length;
        int m2cols = m2[0].length;
        if (m1cols != m2rows)
            throw new IllegalArgumentException(
                "matrices don't match: " + m1cols + " != " + m2rows);
        int[][] result = new int[m1rows][m2cols];

        // multiply
        for (int i=0; i<m1rows; i++) {
            for (int j=0; j<m2cols; j++) {
                for (int k=0; k<m1cols; k++) {
                    result[i][j] += m1[i][k] * m2[k][j];
                }
            }
        }

        return result;
    }

    /** Matrix print.
 */
    public static void mprint(int[][] a) {
        int rows = a.length;
        int cols = a[0].length;
        System.out.println("array["+rows+"]["+cols+"] = {");
        for (int i=0; i<rows; i++) {
            System.out.print("{");
            for (int j=0; j<cols; j++)
                System.out.print(" " + a[i][j] + ",");
            System.out.println("},");
        }
        System.out.println("};");
    }
}

这里有一个使用Matrix类来计算两个int数组乘积的程序:

        int x[][] = {
            { 3, 2, 3 },
            { 5, 9, 8 },
        };
        int y[][] = {
            { 4, 7 },
            { 9, 3 },
            { 8, 1 },
        };
        int z[][] = Matrix.multiply(x, y);
        Matrix.mprint(x);
        Matrix.mprint(y);
        Matrix.mprint(z);

参见

查阅数值方法书籍以获取更多有关矩阵的操作;我们的一位评论员推荐系列书籍《数值秘籍》,可在http://nrbook.com获取。 (请注意,该站点有链接到他们的新网站,https://numerical.recipes,但该站点需要 Adobe Flash,大多数浏览器由于安全原因不再支持。)书中的代码有多种语言的翻译版本,包括Java。价格因套餐而异。

商业软件包可以为您执行一些计算;例如,您可以查看Rogue Wave Software提供的数值库。

5.11 使用复数

问题

您需要处理复数,这在数学、科学或工程应用中很常见。

解决方案

Java 没有提供专门支持处理复数的功能。你可以跟踪实部和虚部并自行计算,但这不是一个很好的解决方案。

当然,更好的解决方案是使用实现复数的类。我曾经写过这样的一个类,但现在我建议使用 Apache Commons Math 库。这个库的构建坐标是org.apache.commons:commons-math3:3.6.1(或更新版本)。首先,让我们看一个使用 Apache 库的例子:

public class ComplexDemoACM {

    public static void main(String[] args) {
        Complex c = new Complex(3,  5);
        Complex d = new Complex(2, -2);
        System.out.println(c);
        System.out.println(c + ".getReal() = " + c.getReal());
        System.out.println(c + " + " + d + " = " + c.add(d));
        System.out.println(c + " + " + d + " = " + c.add(d));
        System.out.println(c + " * " + d + " = " + c.multiply(d));
        System.out.println(c.divide(d));
    }
}

运行这个演示程序会产生以下输出:

(3.0, 5.0)
(3.0, 5.0).getReal() = 3.0
(3.0, 5.0) + (2.0, -2.0) = (5.0, 3.0)
(3.0, 5.0) + (2.0, -2.0) = (5.0, 3.0)
(3.0, 5.0) * (2.0, -2.0) = (16.0, 4.0)
(-0.5, 2.0)

示例 5-7 是我版本的Complex类的源代码,不需要过多解释。尽管 Apache 版本更加复杂,但我留下我的版本只是为了解释复数的基本操作。

为了保持 API 的通用性,我为每个 add、subtract 和 multiply 操作都提供了一个静态方法,用于两个复杂对象,以及一个非静态方法,将操作应用于给定对象和另一个对象。

示例 5-7. main/src/main/java/numbers/Complex.java
public class Complex {
    /** The real part */
    private double r;
    /** The imaginary part */
    private double i;

    /** Construct a Complex */
    Complex(double rr, double ii) {
        r = rr;
        i = ii;
    }

    /** Display the current Complex as a String, for use in
 * println() and elsewhere.
 */
    public String toString() {
        StringBuilder sb = new StringBuilder().append(r);
        if (i>0)
            sb.append('+');    // else append(i) appends - sign
        return sb.append(i).append('i').toString();
    }

    /** Return just the Real part */
    public double getReal() {
        return r;
    }
    /** Return just the Real part */
    public double getImaginary() {
        return i;
    }
    /** Return the magnitude of a complex number */
    public double magnitude() {
        return Math.sqrt(r*r + i*i);
    }

    /** Add another Complex to this one
 */
    public Complex add(Complex other) {
        return add(this, other);
    }

    /** Add two Complexes
 */
    public static Complex add(Complex c1, Complex c2) {
        return new Complex(c1.r+c2.r, c1.i+c2.i);
    }

    /** Subtract another Complex from this one
 */
    public Complex subtract(Complex other) {
        return subtract(this, other);
    }

    /** Subtract two Complexes
 */
    public static Complex subtract(Complex c1, Complex c2) {
        return new Complex(c1.r-c2.r, c1.i-c2.i);
    }

    /** Multiply this Complex times another one
 */
    public Complex multiply(Complex other) {
        return multiply(this, other);
    }

    /** Multiply two Complexes
 */
    public static Complex multiply(Complex c1, Complex c2) {
        return new Complex(c1.r*c2.r - c1.i*c2.i, c1.r*c2.i + c1.i*c2.r);
    }

    /** Divide c1 by c2.
 * @author Gisbert Selke.
 */
    public static Complex divide(Complex c1, Complex c2) {
        return new Complex(
            (c1.r*c2.r+c1.i*c2.i)/(c2.r*c2.r+c2.i*c2.i),
            (c1.i*c2.r-c1.r*c2.i)/(c2.r*c2.r+c2.i*c2.i));
    }

    /* Compare this Complex number with another
 */
    public boolean equals(Object o) {
        if (o.getClass() != Complex.class) {
            throw new IllegalArgumentException(
                    "Complex.equals argument must be a Complex");
        }
        Complex other = (Complex)o;
        return r == other.r && i == other.i;
    }

    /* Generate a hashCode; not sure how well distributed these are.
 */
    public int hashCode() {
        return (int)(r) |  (int)i;
    }
}

5.12 处理非常大的数字

问题

您需要处理大于 Long.MAX_VALUE 的整数或大于 Double.MAX_VALUE 的浮点数值。

解决方案

java.math 包中使用 BigIntegerBigDecimal 值,如 示例 5-8 所示。

示例 5-8. main/src/main/java/numbers/BigNums.java
        System.out.println("Here's Long.MAX_VALUE: " + Long.MAX_VALUE);
        BigInteger bInt = new BigInteger("3419229223372036854775807");
        System.out.println("Here's a bigger number: " + bInt);
        System.out.println("Here it is as a double: " + bInt.doubleValue());

注意构造函数将数字作为字符串。显然,您不能只键入数值数字,因为按定义,这些类设计用于表示超过 Java long 能容纳的数字。

讨论

BigIntegerBigDecimal 对象都是不可变的;也就是说,一旦构造完成,它们始终表示一个给定的数字。尽管如此,许多方法会返回原始对象的新对象,例如 negate() 方法,它返回给定 BigIntegerBigDecimal 的负数。还有许多方法对应于 Java 语言中基本类型 int/longfloat/double 上定义的大多数内置运算符。除法方法需要指定舍入方法;有关详细信息,请参阅数值分析书籍。示例 5-9 是一个简单的基于堆栈的计算器,使用 BigDecimal 作为其数值数据类型。

示例 5-9. main/src/main/java/numbers/BigNumCalc.java
public class BigNumCalc {

    /** an array of Objects, simulating user input */
    public static Object[] testInput = {
        new BigDecimal("3419229223372036854775807.23343"),
        new BigDecimal("2.0"),
        "*",
    };

    public static void main(String[] args) {
        BigNumCalc calc = new BigNumCalc();
        System.out.println(calc.calculate(testInput));
    }

    /**
 * Stack of numbers being used in the calculator.
 */
    Stack<BigDecimal> stack = new Stack<>();

    /**
 * Calculate a set of operands; the input is an Object array containing
 * either BigDecimal objects (which may be pushed onto the Stack) and
 * operators (which are operated on immediately).
 * @param input
 * @return
 */
    public BigDecimal calculate(Object[] input) {
        BigDecimal tmp;
        for (int i = 0; i < input.length; i++) {
            Object o = input[i];
            if (o instanceof BigDecimal) {
                stack.push((BigDecimal) o);
            } else if (o instanceof String) {
                switch (((String)o).charAt(0)) {
                // + and * are commutative, order doesn't matter
                case '+':
                    stack.push((stack.pop()).add(stack.pop()));
                    break;
                case '*':
                    stack.push((stack.pop()).multiply(stack.pop()));
                    break;
                // - and /, order *does* matter
                case '-':
                    tmp = (BigDecimal)stack.pop();
                    stack.push((stack.pop()).subtract(tmp));
                    break;
                case '/':
                    tmp = stack.pop();
                    stack.push((stack.pop()).divide(tmp,
                        BigDecimal.ROUND_HALF_UP));
                    break;
                default:
                    throw new IllegalStateException("Unknown OPERATOR popped");
                }
            } else {
                throw new IllegalArgumentException("Syntax error in input");
            }
        }
        return stack.pop();
    }
}

运行此程序将生成预期的(非常大的)值:

> javac -d . numbers/BigNumCalc.java
> java numbers.BigNumCalc
6838458446744073709551614.466860
>

当前版本的输入是硬编码的,JUnit 测试程序也是如此,但在实际应用中,您可以使用正则表达式从输入流中提取单词或操作符(如 Recipe 4.5 中所述),或者可以使用简单计算器的 StreamTokenizer 方法(请参阅 Recipe 10.5)。数字堆栈是使用 java.util.Stack 维护的(请参阅 Recipe 7.16)。

BigInteger 主要用于加密和安全应用。其方法 isProbablyPrime() 可以为公钥密码生成素数对。BigDecimal 在计算宇宙大小时也可能很有用。

5.13 程序:TempConverter

示例 5-10 中显示的程序打印了华氏温度表(仍然在美国及其领土、利比里亚和一些加勒比国家的日常天气报告中使用),以及相应的摄氏温度(在全球科学界和其他地方的日常生活中使用)。

示例 5-10. main/src/main/java/numbers/TempConverter.java
public class TempConverter {

    public static void main(String[] args) {
        TempConverter t = new TempConverter();
        t.start();
        t.data();
        t.end();
    }

    protected void start() {
    }

    protected void data() {
        for (int i=-40; i<=120; i+=10) {
            double c = fToC(i);
            print(i, c);
        }
    }

    public static double cToF(double deg) {
        return ( deg * 9 / 5) + 32;
    }

    public static double fToC(double deg) {
        return ( deg - 32 ) * ( 5d / 9 );
    }

    protected void print(double f, double c) {
        System.out.println(f + " " + c);
    }

    protected void end() {
    }
}

这有效,但这些数字打印时带有约 15 位(无用的)小数部分!此程序的第二个版本是第一个版本的子类,并使用printf(参见配方 10.4)控制转换后温度的格式(参见示例 5-11)。现在它看起来正常,假设您正在等宽字体中打印。

示例 5-11. main/src/main/java/numbers/TempConverter2.java
public class TempConverter2 extends TempConverter {

    public static void main(String[] args) {
        TempConverter t = new TempConverter2();
        t.start();
        t.data();
        t.end();
    }

    @Override
    protected void print(double f, double c) {
        System.out.printf("%6.2f %6.2f%n", f, c);
    }

    @Override
    protected void start() {
        System.out.println("Fahr    Centigrade");
    }

    @Override
    protected void end() {
        System.out.println("-------------------");
    }
}
C:\javasrc\numbers>java numbers.TempConverter2
Fahr    Centigrade
-40.00 -40.00
-30.00 -34.44
-20.00 -28.89
-10.00 -23.33
  0.00 -17.78
 10.00 -12.22
 20.00  -6.67
 30.00  -1.11
 40.00   4.44
 50.00  10.00
 60.00  15.56
 70.00  21.11
 80.00  26.67
 90.00  32.22
100.00  37.78
110.00  43.33
120.00  48.89

5.14 程序:数字回文

我的妻子贝蒂最近提醒我一个定理,我高中时肯定学过,但其名称我早已忘记:任何正整数都可以通过将其与其数字逆序构成的数相加来生成一个回文数。回文数是指在任何方向上都读取相同的序列,例如姓名“安娜”或短语“Madam, I’m Adam”(忽略空格和标点)。我们通常认为回文是由文本组成的,但这个概念也可以应用于数字:13,531 是一个回文数。例如,从数字 72 开始,加上其反向数字 27。这个加法的结果是 99,是一个(短)回文数。从 142 开始,加上 241,得到 383。有些数字需要多次尝试才能生成回文数。例如,1,951 + 1,591 得到 3,542,不是回文的。然而,第二轮,3,542 + 2,453 得到 5,995,是回文的。我儿子本杰明随意挑选了 17,892,需要 12 轮才能生成一个回文数,但最终还是成功了:

C:\javasrc\numbers>java  numbers.Palindrome 72 142 1951 17892
Trying 72
72->99
Trying 142
142->383
Trying 1951
Trying 3542
1951->5995
Trying 17892
Trying 47763
Trying 84537
Trying 158085
Trying 738936
Trying 1378773
Trying 5157504
Trying 9215019
Trying 18320148
Trying 102422529
Trying 1027646730
Trying 1404113931
17892->2797227972

C:\javasrc\numbers>

如果对您来说这听起来像是递归的一个自然候选项,那么您是正确的。递归涉及将问题分解为简单且相同的步骤,可以由调用自身的函数实现,并提供终止的方式。如我们所示的findPalindrome方法的基本方法如下:

long findPalindrome(long num) {
    if (isPalindrome(num))
        return num;
    return findPalindrome(num + reverseNumber(num));
}

也就是说,如果起始数字已经是回文数,返回它;否则,将它加到它的反向数字上,并再次尝试。此处显示的代码版本直接处理简单情况(例如单个数字始终是回文的)。我们不考虑负数,因为这些负数有一个位于末尾时会失去意义的字符,并且因此不严格是回文的。此外,某些数字的回文形式太长,无法适应 Java 的 64 位long整数。这会导致下溢,被捕获。因此,会报告“太大”的错误消息。³说了这么多,示例 5-12 展示了这段代码。

示例 5-12. main/src/main/java/numbers/Palindrome.java
public class Palindrome {

    public static boolean verbose = true;

    public static void main(String[] argv) {
        for (String num : argv) {
            try {
                long l = Long.parseLong(num);
                if (l < 0) {
                    System.err.println(num + " -> TOO SMALL");
                    continue;
                }
                System.out.println(num + "->" + findPalindrome(l));
            } catch (NumberFormatException e) {
                System.err.println(num + "-> INVALID");
            } catch (IllegalStateException e) {
                System.err.println(num + "-> " + e);
            }
        }
    }

    /** find a palindromic number given a starting point, by
 * recursing until we get a number that is palindromic.
 */
    static long findPalindrome(long num) {
        if (num < 0)
            throw new IllegalStateException("negative");
        if (isPalindrome(num))
            return num;
        if (verbose)
             System.out.println("Trying " + num);
        return findPalindrome(num + reverseNumber(num));
    }

    /** The number of digits in Long.MAX_VALUE */
    protected static final int MAX_DIGITS = 19;

    // digits array is shared by isPalindrome and reverseNumber,
    // which cannot both be running at the same time.

    /* Statically allocated array to avoid new-ing each time. */
    static long[] digits = new long[MAX_DIGITS];

    /** Check if a number is palindromic. */
    static boolean isPalindrome(long num) {
        // Consider any single digit to be as palindromic as can be
        if (num >= 0 && num <= 9)
            return true;

        int nDigits = 0;
        while (num > 0) {
            digits[nDigits++] = num % 10;
            num /= 10;
        }
        for (int i=0; i<nDigits/2; i++)
            if (digits[i] != digits[nDigits - i - 1])
                return false;
        return true;
    }

    static long reverseNumber(long num) {
        int nDigits = 0;
        while (num > 0) {
            digits[nDigits++] = num % 10;
            num /= 10;
        }
        long ret = 0;
        for (int i=0; i<nDigits; i++) {
            ret *= 10;
            ret += digits[i];
        }
        return ret;
    }
}

虽然它不严格是一个数值解决方案,但丹尼尔·伊诺霍萨指出,您可以使用StringBuilder来执行反转部分,从而得到更短、更优雅的代码,只有稍微慢一点:

    static boolean isPalindrome(long num) {
        long result = reverseNumber(num);
        return num == result;
    }

    private static long reverseNumber(long num) {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(num);
        return Long.parseLong(stringBuilder.reverse().toString());
    }

他的完整代码版本在文件PalindromeViaStringBuilder.java中。

参见

使用 Java 进行科学或大规模数值计算的人士可能希望关注即将推出的值类型,来自 Java 的“Valhalla 项目”。另请参阅 2019 年的演示标题为 “JVM 上的向量和数值计算”

¹ 低成本的随机源,请查看现已停止运行的 Lavarand。该过程利用了 1970 年代的熔岩灯视频进行“硬件基础”的随机性提供。有趣!

² 请注意,完全由编译时常量组成的表达式,如 Math.PI \* 2.1e17,也被视为 Strict-FP。

³ 某些数值不适用;例如,Ashish Batia 报告说这个版本在数值 8,989 上会抛出异常(确实如此)。

第六章:日期和时间

6.0 引言

开发人员在 Java 1.0 的 Date 类和其替代品 Java 1.1 的 Calendar 类的不一致性和模糊性下遭受了长达十五年的苦难。出现了几种替代 Date 的包,包括简单而明智的 Date4J 和更全面的 Joda-Time 包。Java 8 引入了一个新的、一致且经过深思熟虑的日期和时间处理包,由 Java 社区流程 JSR-310 主持,由开发人员 Stephen Colebourne 提出,基于他早期的 Joda-Time 包,但进行了几个重要的设计更改。¹ 该包偏向于 ISO 8601 日期;例如,默认格式为 2015-10-23T10:22:45。当然,它也可以与其他日历方案一起使用。

新 API 的一个关键优势是它提供了像添加/减去日期/时间等 有用的操作。开发人员曾多次浪费时间重复实现这些有用的操作。有了新的 API,可以使用内置功能。尽管如此,数百万行代码基于旧的 API,因此我们将简要回顾它们,并在本章的最后一节(Recipe 6.9)考虑将新 API 与遗留代码进行接口化。

新 API 的另一个优点是几乎所有对象都是不可变的,因此是线程安全的。随着我们迅速进入大规模并行的时代,这可能带来相当大的好处。

由于没有 set 方法,因此 getter 方法范式并不总是有意义,API 提供了一系列新方法来替换这些方法,列在 表 6-1 中。

表 6-1。新日期/时间 API:常见方法

Name Description
at 与另一个对象结合
format 使用提供的格式化器生成格式化字符串
from 工厂:将输入参数转换为目标实例
get 从实例中检索一个字段
is 检查给定对象的状态
minus 返回减去给定量的副本
now BuilderFactory:获取当前时间、日期等
of 工厂:通过解析输入创建新的方法
parse 工厂:解析单个输入字符串以生成目标实例
plus 返回添加了给定量的副本
to 将此对象转换为另一种类型
with 返回更改了给定字段的副本;替换 set 方法

JSR 310 API 指定了大约十几个主要类。表示时间的那些类要么是连续时间,要么是人类时间。连续时间 基于 Unix 时间,这是计算机时间的黎明时期的更深层真相,表示为单个单调递增的数字。Unix 中的时间值 0 代表 1970 年 1 月 1 日 UTC 的第一秒,大约是 Unix 发明的时候。每个增量单位代表一秒钟的时间。自从 Y2K 事件后,大多数 Unix 系统已经在 2038 年前通过将时间值从 32 位转换为 64 位来静静地避免了可能的 Y2038 事件。Java 也使用了这种时间基准,但使用了 64 位,并且以毫秒为单位存储其时间,因为自 1970 年以来的 64 位毫秒时间不会在未来几年内溢出(请在您的日历中保留此日期——292,278,994 年 8 月 17 日)。下面是我得到该日期的计算:

        Date endOfTime = new Date(Long.MAX_VALUE);
        System.out.println("Java time overflows on " + endOfTime);

新 API 包含五个包,如表 6-2 所示;通常,顶层包含最常用的部分。

表 6-2. 新的日期/时间 API:包

名称 描述
java.time 日期、时间、时刻和持续时间的通用类
java.time.chrono 非 ISO 日历系统的 API
java.time.format 格式化类(参见 Recipe 6.2)
java.time.temporal 使用字段、单位和调整器访问日期和时间
java.time.zone 支持时区及其规则

基础的java.time包包含大约十几个类,以及几个枚举和一个通用异常(在表格 6-3、6-4 和 6-5 中显示)。

表 6-3. 新的日期/时间 API:基础

描述
Clock 可替换的工厂,用于获取当前时间
Instant 表示自 1970 年 1 月 1 日以来的某一时刻,以纳秒表示
Duration 表示一段时间,以纳秒表示

人类时间将时间和日期表示为我们日常生活中使用的方式。这些类在表 6-4 中列出。

表 6-4. 新的日期/时间 API:人类时间

描述
Calendrical 连接到低级 API
DateTimeFields 存储字段-值对的映射,这些对不需要保持一致
DayOfWeek 一周的某一天(例如星期二)
LocalDate 仅表示日期(日、月、年),没有任何调整
LocalTime 仅表示时间(小时、分钟、秒),没有任何调整
LocalDateTime 上述所有内容的组合
MonthDay 月份和日期
OffsetTime 带有时区偏移(例如 -04:00)的某一天的时间,没有日期或时区
OffsetDateTime 带有时间偏移的日期和时间,如 –04:00,但没有时区
Period 描述性的时间量,例如“2 个月 3 天”
ZonedDateTime 带有时区和偏移量的日期和时间
Year 一个年份
YearMonth 年和月份

几乎所有顶级类直接扩展 java.lang.Object 并通过各种接口保持一致性,这些接口在子包中声明。日期和时间类大多实现了 Comparable,这是有意义的。

表 6-5 展示了与 ZonedDateTime, OffsetDateTime, 和 OffsetTime 一起使用的两个特定于时区的类。

表 6-5. 新的日期/时间 API:支持

描述
ZoneOffset 从 UTC 偏移的时间偏移量(小时,分钟,秒)
ZoneId 定义诸如 Canada/Eastern 等时区及其转换规则

新 API 是流畅 API,大多数操作返回它们所操作的对象,因此您可以在不需要繁琐和烦人的临时变量的情况下链式调用多个调用:

LocalTime time = LocalTime.now().minusHours(5); // the time 5 hours ago

在我看来,这样会产生更自然和方便的编码风格。如果您愿意,总是可以编写带有大量临时变量的代码;最后需要读这些代码的是您自己。

6.1 寻找今天的日期

问题

您希望找到今天的日期和/或时间。

解决方案

调用适当的构建器以获取 LocalDate, LocalTime, 或 LocalDateTime 对象,并调用其 toString() 方法。

讨论

这些类没有公共构造函数,因此您需要调用其中一个工厂方法来获取实例。它们都提供一个 now() 方法,其功能如其名称所示。CurrentDateTime 演示程序展示了所有三个类的简单用法:

public class CurrentDateTime {
    public static void main(String[] args) {
        LocalDate dNow = LocalDate.now();
        System.out.println(dNow);
        LocalTime tNow = LocalTime.now();
        System.out.println(tNow);
        LocalDateTime now = LocalDateTime.now();
        System.out.println(now);
    }
}

运行它会产生以下输出:

2013-10-28
22:23:55.641
2013-10-28T22:23:55.642

格式化并不引人注目,但足够使用。我们将在食谱 6.2 中处理更复杂的格式化。

尽管这样可以工作,在大型应用程序中,建议将一个 Clock 实例传递给所有的 now() 方法。Clock 是一个工厂对象,用于在内部查找当前时间。在测试中,通常希望使用已知的日期或时间进行比较以获取已知的输出。Clock 类使这变得容易。示例 6-1 使用了一个 Clock 并允许通过调用 setter 来替换默认的 Clock。或者,您可以使用像 CDI 或 Spring 这样的依赖注入框架来提供正确版本的 Clock 类。

示例 6-1. main/src/main/java/datetime/TestableDateTime
package datetime;

import java.time.Clock;
import java.time.LocalDateTime;

/**
 * TestableDateTime allows test code to plug in a Fixed clock
 */
public class TestableDateTime {
    private static Clock clock = Clock.systemDefaultZone();
    public static void main(String[] args) {
        System.out.println("It is now " + LocalDateTime.now(clock));
    }
    public static void setClock(Clock clock) {
        TestableDateTime.clock = clock;
    }
}

在正常操作中,会获取当前日期和时间。在测试中,您可以使用 setClock() 方法和从静态方法 Clock.fixed(Instant fixedInstant, ZoneId zone) 获得的 Clock 实例,传入您的测试代码期望的时间。固定时钟不会滴答,所以在将时钟设置为固定时和调用测试之间的毫秒数不必担心。

6.2 格式化日期和时间

问题

您希望为日期和时间对象提供更好的格式化。

解决方案

使用 java.time.format.DateTimeFormatter

讨论

DateTimeFormatter 类提供了大量可能的格式化样式。如果您不想使用提供的约 20 种预定义格式之一,可以使用 DateTimeFormatter.ofPattern(String pattern) 来定义自己的格式。pattern 字符串可以包含任何字符,但除了明显的 Y, M, D, h, m, 和 s 外,几乎每个字母都有其特定含义。此外,引号字符和方括号也有定义,井号 (#) 和花括号则保留供将来使用。

如日期格式化语言通常所示,模式中字母的重复次数提示了其意图的详细程度。因此,例如,“MMM” 表示 “Jan”,而 “MMMM” 表示 “January”。

表格 6-6 是从 JSR-310 的 javadoc 改编的格式字符完整列表的尝试。

表格 6-6. DateFormatter 格式字符

符号 含义 显示方式 示例
G 纪元 文本 AD; Anno Domini
y 年份 年份 2004; 04
u 年份 年份 见备注。
D 年份中的日期 数字 189
M/L 月份 数字/文本 7; 07; Jul; July; J
d 月份中的日期 数字 10
Q/q 年份的季度 数字/文本 3; 03; Q3, 3rd quarter
Y 基于周的年份 年份 1996; 96
w 基于年的周数 数字 27
W 月中的周数 数字 4
e/c 本地化的星期几 数字/文本 2; 02; Tue; Tuesday; T
E 星期几 文本 Tue; Tuesday; T
F 月中的周数 数字 3
a 上午/下午 文本 PM
h 上午/下午的小时数 (1-12) 数字 12
K 上午/下午的小时数 (0-11) 数字 0
k 上午/下午的小时数 (1-24) 数字 0
H 一天中的小时数 (0-23) 数字 0
m 小时中的分钟数 数字 30
s 分钟中的秒数 数字 55
S 秒的分数 分数 978
A 一天中的毫秒数 数字 1234
n 秒中的纳秒数 数字 987654321
N 一天中的纳秒数 数字 1234000000
V 时区 ID 时区 ID America/Los_Angeles; Z; –08:30
z 时区名称 时区名称 Pacific Standard Time; PST
X 零时区偏移量 Z 偏移量-X Z; –08; –0830; –08:30; –083015; –08:30:15;
x 时区偏移 偏移-x +0000; –08; –0830; –08:30; –083015; –08:30:15;
Z 时区偏移 偏移-Z +0000; –0800; –08:00;
O 本地化时区偏移 偏移-O GMT+8; GMT+08:00; UTC–08:00;
p 下一个填充 填充修饰符 1
注意

yu 对于公元年份是相同的;但是,对于公元前 3 年,y 模式返回 3,而 u 模式返回 -2(也称为预测年)。

示例 6-2 包含一些在字符串和日期之间进行双向转换的示例。

示例 6-2. main/src/main/java/datetime/DateFormatter.java(示例日期格式化和解析)
public class DateFormatter {
    public static void main(String[] args) {

        // Format a date ISO8601-like but with slashes instead of dashes
        DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy/LL/dd");
        System.out.println(df.format(LocalDate.now()));

        // Parse a String to a date using the same formatter
        System.out.println(LocalDate.parse("2014/04/01", df));

        // Format a Date and Time without timezone information
        DateTimeFormatter nTZ =
            DateTimeFormatter.ofPattern("d MMMM, yyyy h:mm a");
        System.out.println(ZonedDateTime.now().format(nTZ));
    }
}

6.3 在日期/时间、YMDHMS 和时代秒之间进行转换

问题

你需要在日期/时间、YMDHMS、时代秒或其他一些数字值之间进行转换。

解决方案

使用适当的日期/时间工厂或检索方法。

讨论

时代是现代操作系统开始的时间。Unix 时间和一些 Windows 时间版本从时代开始不可避免地计算秒数。1970 年,肯·汤普森和丹尼斯·里奇提出这种格式时,秒数看起来像是一个不错的度量单位,32 位的秒数似乎近乎无限。然而,在将时代存储为 32 位整数的操作系统上,时间正在流逝。大多数操作系统的旧版本将其存储为 32 位有符号整数,不幸的是,这将在 2038 年溢出。

Java 刚发布时,它有一个叫做System.currentTimeMillis()的方法,以毫秒精度表示时代秒。新的 Java API 使用的是纳秒时代,仍然处于相同的时间基准上,可以通过调用System.nanoTime()获得。

这些与时代相关的任何数字都可以转换为本地日期/时间,也可以从中获取。还可以使用其他数字,如整数年份、月份和天数。通常情况下,有一些工厂方法可以在请求更改时创建新对象。下面是一个演示这些转换的程序:

main/src/main/java/datetime/DateConversions.java

        // Convert a number of seconds since the epoch to a local date/time
        Instant epochSec = Instant.ofEpochSecond(1000000000L);
        ZoneId zId = ZoneId.systemDefault();
        ZonedDateTime then = ZonedDateTime.ofInstant(epochSec, zId);
        System.out.println("The epoch was a billion seconds old on " + then);

        // Convert a date/time to epoch seconds
        long epochSecond = ZonedDateTime.now().toInstant().getEpochSecond();
        System.out.println("Current epoch seconds = " + epochSecond);

        LocalDateTime now = LocalDateTime.now();
        ZonedDateTime there = now.atZone(ZoneId.of("Canada/Pacific"));
        System.out.printf("When it's %s here, it's %s in Vancouver%n",
            now, there);

6.4 将字符串解析为日期

问题

你需要将用户输入转换为java.time对象。

解决方案

使用parse()方法。

讨论

许多日期/时间类都有一个parse()工厂方法,试图将字符串解析为该类的对象。例如,LocalDate.parse(String)返回一个给定日期的LocalDate对象:

public class DateParse {
    public static void main(String[] args) {

        String armisticeDate = "1914-11-11";
        LocalDate aLD = LocalDate.parse(armisticeDate);
        System.out.println("Date: " + aLD);

        String armisticeDateTime = "1914-11-11T11:11";
        LocalDateTime aLDT = LocalDateTime.parse(armisticeDateTime);
        System.out.println("Date/Time: " + aLDT);

正如你现在可能预期的那样,默认格式是 ISO8601 日期格式。但是,我们经常需要处理其他格式的日期。为此,DateTimeFormatter允许您指定特定的模式。例如,“dd MMM uuuu”表示月份中的日期(两位数字)、月份名称的三个字母(Jan、Feb、Mar,…)和四位数字年份:

        DateTimeFormatter df = DateTimeFormatter.ofPattern("dd MMM uuuu");
        String anotherDate = "27 Jan 2011";
        LocalDate random = LocalDate.parse(anotherDate, df);
        System.out.println(anotherDate + " parses as " + random);

DateTimeFormatter对象是双向的;它既可以解析输入,也可以格式化输出。我们可以将这一行添加到DateParse示例中:

System.out.println(aLD + " formats as " + df.format(aLD));

当我们运行程序时,输出如下所示:

Date: 1914-11-11
Date/Time: 1914-11-11T11:11
27 Jan 2011 parses as 2011-01-27
1914-11-11 formats as 11 Nov 1914

DateTimeFormatter也是本地化的(参见配方 3.12),可以在调用ofPattern()后调用withLocale()进行配置。

6.5 两个日期之间的差异

问题

你需要计算两个日期之间的差异。

解决方案

使用静态方法Period.between()来找到两个LocalDate之间的差异。

讨论

给定两个LocalDate对象,你可以使用静态方法Period.between()简单地找到它们之间的差异作为一个Period。你可以toString()这个Period,或者如果默认格式不够好,可以自己格式化结果:

import java.time.LocalDate;
import java.time.Period;

/**
 * Tutorial/Example of LocalDate date difference subtraction
 */
public class DateDiff {

    public static void main(String[] args) {
        /** The date at the end of the last century */
        LocalDate endof20thCentury = LocalDate.of(2000, 12, 31);
        LocalDate now = LocalDate.now();
        if (now.getYear() > 2100) {
            System.out.println("The 21st century is over!");
            return;
        }

        Period diff = Period.between(endof20thCentury, now);

        System.out.printf("The 21st century (up to %s) is %s old%n", now, diff);
        System.out.printf(
                "The 21st century is %d years, %d months and %d days old",
                diff.getYears(), diff.getMonths(), diff.getDays());
    }
}

我在 2013 年 10 月底写下了这个配方;公元 20 世纪末在 2000 年末结束,所以值应约为 12¹⁰/[12]年,即:

$ java datetime.DateDiff
The 21st century (up to 2013-10-28) is P12Y9M28D old
The 21st century is 12 years, 9 months and 28 days old

由于 API 的规律性,你可以在LocalTimeLocalDateTime中使用相同的技术。

ChronoUnit也存在,包含许多范围值,比如DAYSHOURSMINUTES等(实际范围从NANOSMILLENIAERAS甚至FOREVER)。如果你想要在某个单位获取差异信息:

jshell> import java.time.temporal.*;

jshell> ChronoUnit.DAYS.between(LocalDate.now(), LocalDate.parse("2022-02-22"))
$6 ==> 786

jshell> ChronoUnit.DECADES.between(LocalDate.of(1970,01,01),
  LocalDate.of(2020,01,01));
$7 ==> 5

Unix 已经进入了它的第五个十年!

参见

讨论配方 6.2 讨论了格式化日期/时间值的高级方法。

6.6 增加或减少日期

问题

你需要对日期加上或减去一个固定的周期。

解决方案

使用如Local⁠Date.plus​(Period.ofDays(N));这样的表达式可以创建过去或未来的日期。

讨论

java.time提供了Period类来表示一段时间,比如几天、几小时和几分钟。LocalDate等类提供了plus()minus()方法来增加或减少Period或其他与时间相关的对象。Period还提供了像ofDays()这样的工厂方法。以下代码计算从现在起 700 天后的日期:

import java.time.LocalDate;
import java.time.Period;

/** DateAdd -- compute the difference between two dates
 * (e.g., today and 700 days from now).
 */
public class DateAdd {
    public static void main(String[] av) {
        /** Today's date */
        LocalDate now =  LocalDate.now();

        Period p = Period.ofDays(700);
        LocalDate then = now.plus(p);

        System.out.printf("Seven hundred days from %s is %s%n", now, then);
    }
}

运行这个程序会报告当前的日期和时间,以及从现在起 700 天后的日期和时间:

Seven hundred days from 2013-11-09 is 2015-10-10

6.7 处理重复事件

问题

你需要处理重复发生的日期,例如每个月的第三个星期三。

解决方案

使用TemporalAdjusters类。

讨论

TemporalAdjuster接口和TemporalAdjusters工厂类提供了大部分处理重复事件所需的功能。有许多有趣而强大的调整器可用,如表 6-7中所示,当然你也可以自己开发。

表 6-7. 新日期/时间 API:TemporalAdjusters 工厂方法

方法签名
public static TemporalAdjuster firstDayOfMonth();
public static TemporalAdjuster lastDayOfMonth();
public static TemporalAdjuster firstDayOfNextMonth();
public static TemporalAdjuster firstDayOfYear();
public static TemporalAdjuster lastDayOfYear();
public static TemporalAdjuster firstDayOfNextYear();
public static TemporalAdjuster firstInMonth(java.time.DayOfWeek);
public static TemporalAdjuster lastInMonth(java.time.DayOfWeek);
public static TemporalAdjuster dayOfWeekInMonth(int, java.time.DayOfWeek);
public static TemporalAdjuster next(java.time.DayOfWeek);
public static TemporalAdjuster nextOrSame(java.time.DayOfWeek);
public static TemporalAdjuster previous(java.time.DayOfWeek);
public static TemporalAdjuster previousOrSame(java.time.DayOfWeek);
public static TemporalAdjuster ofDateAdjuster( java.util.function.UnaryOperator<java.time.LocalDate>);

大多数名称直接告诉您它们的功能。在阅读有关如 UnaryOperator 的函数接口的第九章 Chapter 9 后,最后一个将变得清晰明了。

这些与日期/时间对象的 with() 方法一起使用。例如,GTABUG 小组 (http://gtabug.org) 每月第三个星期三举行会议。在 darwinsys-api 库中有一个 RecurringEventDatePicker 类;其核心部分始于方法 getMeetingDateInMonth(LocalDate dateContainingMonth),在我们的情况下选择给定月份的第三个星期三(在构造函数中设置了 dayOfWeekweekOfMonth)。我们获取月份 (dateContainingMonth),使用 firstInMonth() 工厂方法调整为该月的第一个星期三,然后添加周数以获取正确星期的星期三:

// Variant versions from older version of RecurringDatePicker.java
// First version, not for production use!
private LocalDate getMeetingForMonth(LocalDate dateContainingMonth) {
    return
        dateContainingMonth.with(TemporalAdjusters.firstInMonth(dayOfWeek))
            .plusWeeks(Math.max(0, weekOfMonth - 1));
}

第二个版本简化了它以更好地使用现有的 API:

private LocalDate getMeetingForMonth(LocalDate dateContainingMonth) {
    return dateWithMonth.with(
        TemporalAdjusters.dayOfWeekInMonth(weekOfMonth,dayOfWeek)
}

由于此版本仅包含一个语句,且仅使用两次,我们将其内联到getNextMeeting(int howManyMonthsAway)方法中,该方法返回给定月份中正确日期的LocalDate。其唯一的复杂性在于,对于当前月份,会议可能在今天的日期之前或之后,因此我们进行相应调整:

public LocalDate getEventLocalDate(int meetingsAway) {
    LocalDate thisMeeting = now.with(
        TemporalAdjusters.dayOfWeekInMonth(weekOfMonth,dayOfWeek));
    // Has the meeting already happened this month?
    if (thisMeeting.isBefore(now)) {
        // start from next month
        meetingsAway++;
    }
    if (meetingsAway > 0) {
        thisMeeting = thisMeeting.plusMonths(meetingsAway).
            with(TemporalAdjusters.dayOfWeekInMonth(weekOfMonth,dayOfWeek));
    }
    return thisMeeting;
}

然后在 JavaServer Page (JSP) web 视图中调用此方法(略有简化;真实代码具有 JavaScript 中的 Add To Calendar API 的复杂性)。如果您尚未使用 JSP,请直接输出纯 HTML 代码,<% %> 标签的内容将被执行,<%= %> 标签的内容将被评估并打印到 HTML 页面中,例如:

Upcoming Meetings:
<ul>
    <%
    RecurringEventDatePicker mp =
      new RecurringEventDatePicker(3, DayOfWeek.WEDNESDAY);
    DateTimeFormatter dfm = DateTimeFormatter.ofPattern("MMMM dd, yyyy");
    for (int i = 0; i <= 2; i++) {
        LocalDateTime dt = mp.getEventLocalDateTime(i);
    %>
    <li>
        <%= dt.format(dfm) %>
    </li>
    <%
    }
    %>
</ul>

当在 2015 年的六月或七月访问此网站时,您可能会看到类似以下内容:

Upcoming Meetings:

* July 15, 2015
* August 19, 2015
* September 16, 2015

6.8 计算涉及时区的日期

问题

想象一个问题:“您的孩子正在从多伦多飞往伦敦的跨大西洋航班,从实际起飞时间(YYZ)起需 5 小时 10 分钟。您的岳父母需要一个小时到达 LHR 并找到停车位。您应该在什么时间打电话告诉他们去机场?”

解决方案

解决方案需要考虑时区差异。可以使用 ZonedDateTime 类及其 plus()minus() 方法来解决。

讨论

基本步骤如示例 Example 6-3 中所示。

示例 6-3. main/src/main/java/datetime/FlightArrivalTimeCalc.java
public class FlightArrivalTimeCalc {

    static Duration driveTime = Duration.ofHours(1);

    public static void main(String[] args) {
        LocalDateTime when = null;
        if (args.length == 0) {
            when = LocalDateTime.now();                                        ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        } else {
            String time = args[0];
            LocalTime localTime = LocalTime.parse(time);
            when = LocalDateTime.of(LocalDate.now(), localTime);               ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        }
        calulateArrivalTime(when);
    }

    public static ZonedDateTime calulateArrivalTime(LocalDateTime takeOffTime) {
        ZoneId torontoZone = ZoneId.of("America/Toronto"),
                londonZone = ZoneId.of("Europe/London");
        ZonedDateTime takeOffTimeZoned =
            ZonedDateTime.of(takeOffTime, torontoZone);                        ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        Duration flightTime =
            Duration.ofHours(5).plus(10, ChronoUnit.MINUTES);                  ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
        ZonedDateTime arrivalTimeUnZoned = takeOffTimeZoned.plus(flightTime);  ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        ZonedDateTime arrivalTimeZoned =
            arrivalTimeUnZoned.toInstant().atZone(londonZone);                 ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
        ZonedDateTime phoneTimeHere = arrivalTimeUnZoned.minus(driveTime);     ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/6.png)

        System.out.println("Flight departure time " + takeOffTimeZoned);
        System.out.println("Flight expected length: " + flightTime);
        System.out.println(
            "Flight arrives there at " + arrivalTimeZoned + " London time.");
        System.out.println("You should phone at " + phoneTimeHere + " Toronto time");
        return arrivalTimeZoned;
    }
}

1

获取出发时间作为 LocalDateTime(如果没有传入参数到 main(),则默认为 now(),假设我们在飞机起飞时运行应用程序)。

2

将出发时间转换为 ZonedDateTime

3

将航班时间转换为 Duration

4

通过将出发时间加上飞行持续时间来获取到达时间。

5

使用 atZone() 将到达时间转换为伦敦时间。

6

由于全家需要一个小时才能到达机场,从到达时间减去这段时间。这会得出你应该打电话的时间。

6.9 与旧版日期和日历类的接口

问题

您需要处理旧的 DateCalendar 类。

解决方案

假设您的代码使用原始的 java.util.Datejava.util.Calendar,您可以根据需要使用转换方法转换值。

讨论

新 API 中的所有类和接口都被选择为避免与传统 API 冲突。因此,在相同的代码中导入这两个包可能是可能的,并且在一段时间内将是常见的。

为了保持新的 API 的整洁,大部分必要的转换例程被添加到旧 API中。Table 6-8 总结了这些转换例程;请注意,如果显示的是以大写类名调用的静态方法,则这些方法是静态的,否则它们是实例方法。

Table 6-8. 传统日期/时间互操作性

传统类 转换为传统 转换为现代
java.util.Date date.from(Instant) Date.toInstant()
java.util.Calendar calendar.toInstant() -
java.util.GregorianCalendar GregorianCalendar.from(ZonedDateTime) calendar.toZonedDateTime()
java.util.TimeZone - timeZone.toZoneId()
java.time.DateTimeFormatter - dateTimeFormatter.toFormat()

Example 6-4 展示了其中一些 API 的运行情况。

Example 6-4. main/src/main/java/datetime/LegacyDates.java
public class LegacyDates {
    public static void main(String[] args) {

        // There and back again, via Date
        Date legacyDate = new Date();
        System.out.println(legacyDate);

        LocalDateTime newDate =
            LocalDateTime.ofInstant(legacyDate.toInstant(),
            ZoneId.systemDefault());
        System.out.println(newDate);

        Date backAgain =
            Date.from(newDate.atZone(ZoneId.systemDefault()).toInstant());
        System.out.println("Converted back as " + backAgain);

        // And via Calendar
        Calendar c = Calendar.getInstance();
        System.out.println(c);
        LocalDateTime newCal =
            LocalDateTime.ofInstant(c.toInstant(),
            ZoneId.systemDefault());
        System.out.println(newCal);
    }
}

当然,您不必使用这些旧的转换器;您可以自由地编写自己的转换器。如果您希望追求这种选择,javasrc 仓库中的 LegacyDatesDIY.java 文件探讨了这个选项。

鉴于在 Java 8 之前编写的大量代码,直到 Java 的终结,传统的 DateCalendar 可能会存在。

新的日期/时间 API 有许多我们尚未探索的功能。事实上,这几乎足以编写一本关于该主题的小书。同时,您可以在 Oracle 学习 API 的详细信息。

¹ 对于那些对历史奥秘感兴趣的人来说,这些差异在他的博客上有详细记录。

第七章:用 Java 结构化数据

7.0 引言

几乎每个超出“Hello, World”的应用程序都需要跟踪一些结构化数据。一个简单的数值问题可能只涉及三四个数字,但大多数应用程序有一组类似的数据项。基于 GUI 的应用程序可能需要跟踪多个对话框窗口。个人信息管理器(PIM)需要跟踪多个人的信息。操作系统需要跟踪谁有登录权限,当前谁已登录以及这些用户正在做什么。图书馆需要跟踪借出书籍的人以及归还日期。网络服务器可能需要跟踪其活跃客户端。这里出现了一个模式,围绕传统称为数据 结构化的变体。

在运行程序的内存中存在数据结构;在磁盘文件中的数据中存在结构;以及存储在数据库中的信息也有结构。在本章中,我们集中讨论第一个方面:内存中的数据。我们将在第十章中讨论第二个方面;第三个方面不在本书的范围之内。

如果你需要考虑内存中的数据,你可能想把它比作存放在文件盒中的索引卡集合,或者像一场寻宝游戏,每一个线索都指向下一个。或者你可以把它想象成我的书桌——看似散乱,实际上是一个充满有意义信息的强大集合。每一个都是 Java 提供的数据结构化类型的好比喻。数组是一种固定长度的线性数据项集合,就像卡片文件盒:它只能容纳那么多,然后溢出。寻宝游戏就像一个称为链表的数据结构。Java 的第一个发布版本没有标准链表类,但你可以编写自己的传统数据结构类(现在仍然可以;你可以在食谱 7.8 看到 DIY 链表的实现)。复杂的集合代表了 Java 的Collection类。名为Collections Framework Overview的文档,分布在 Java 开发工具包文档中(并存储在文件…/docs/guide/collections/overview.html 在线链接),详细讨论了集合框架。Java 集合框架的框架方面在食谱 7.3 中总结。

警惕印刷问题。以常宽字体书写的Arrays指的是java.util.Arrays类;而在正常字体中,“arrays”仅仅是“array”的复数形式(并且会在句首大写)。此外,请注意,HashMapHashSet遵循每个单词边界都有大写字母的规则,而旧的Hashtable不遵循这一规则(t不大写)。

多年来,java.util包已经成为一个大杂烩。除了在 Recipe 6.9 中涵盖的遗留日期/时间 API 外,java.util中还有几个其他类未在本章中涵盖。所有以Abstract开头的类实际上都是抽象的,我们将讨论它们的非抽象子类。StringTokenizer类在 Recipe 3.1 中有所涵盖。BitSet比本章讨论的某些类使用频率低,并且足够简单,可以自行学习。BitSet在内存中非常紧凑地存储位,但因为它早于 Collection API 并且没有进行后续更新,所以它没有实现任何标准的集合接口。还未在此处涵盖的是EnumSetEnumMap,这些专门用于枚举的高效存储/检索。它们比BitSet更新,确实实现了现代集合接口。

我们从数据结构技术讨论开始,其中一个最古老的结构是数组。我们将讨论java.util集合框架的总体结构。然后我们将通过使用java.util中的类进行各种结构化技术的讨论。

7.1 使用数组进行数据结构化

问题

您需要跟踪一定量的信息并(通常)按顺序检索它。

解决方案

使用数组。

讨论

数组可以用来保存任何线性的数据集合。数组中的项必须是相同类型的。您可以创建任何原始类型或对象类型的数组。对于原始类型的数组(如intboolean),数据存储在数组中。对于对象数组,存储的是引用,因此适用于引用变量和转型的常规规则。特别要注意的是,如果数组声明为Object[],则可以在其中存储任何类型的对象引用,而无需转型,尽管在将Object引用取出并用作其原始类型时需要有效的转型。在 Recipe 7.17 中,我会稍微介绍二维数组;否则,您应该将此视为复习示例:

main/src/main/java/lang/Array1.java

public class Array1  {
    @SuppressWarnings("unused")
    public static void main(String[] argv) {
        int[] monthLen1;            // declare a reference
        monthLen1 = new int[12];        // construct it
        int[] monthLen2 = new int[12];    // short form
        // even shorter is this initializer form:
        int[] monthLen3 = {
                31, 28, 31, 30,
                31, 30, 31, 31,
                30, 31, 30, 31,
        };

        final int MAX = 10;
        LocalDate[] days = new LocalDate[MAX];
        for (int i=0; i<MAX; i++) {
            days[i] = LocalDate.of(2022, 02, i + 1);
        }

        // Two-Dimensional Arrays
        // Want a 10-by-24 array
        int[][] me = new int[10][];
        for (int i=0; i<10; i++)
            me[i] = new int[24];

        // Remember that an array has a ".length" attribute
        System.out.println(me.length);
        System.out.println(me[0].length);

    }
}

Java 中的数组工作得很好。类型检查提供了合理的完整性,数组边界始终由运行时系统检查,进一步增加了可靠性。

唯一的问题是数组填满后如果仍有数据进来怎么办?请参见 Recipe 7.2。

7.2 调整数组大小

问题

数组已经填满,并且出现了ArrayIndexOutOfBoundsException

解决方案

扩大数组大小,或者使用ArrayList

讨论

一种方法是一开始就为数组分配一个合理大小;但是如果发现自己的数据超出了容量,就需要重新分配一个更大的数组,并将元素复制到其中。¹ 下面是实现此操作的代码:

main/src/main/java/lang/Array2.java

public class Array2  {
    public final static int INITIAL = 10,   ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        GROW_FACTOR = 2;                    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)

    public static void main(String[] argv) {
        int nDates = 0;
        LocalDateTime[] dates = new LocalDateTime[INITIAL];
        StructureDemo source = new StructureDemo(21);
        LocalDateTime c;
        while ((c=source.getDate()) != null) {

            // if (nDates >= dates.length) {
            //     throw new RuntimeException(
            //         "Too Many Dates! Simplify your life!!");
            // } 
            // better: reallocate, making data structure dynamic
            if (nDates >= dates.length) {
                LocalDateTime[] tmp =
                    new LocalDateTime[dates.length * GROW_FACTOR];
                System.arraycopy(dates, 0, tmp, 0, dates.length);
                dates = tmp;    // copies the array reference
                // old array will be garbage collected soon...
            }
            dates[nDates++] = c;
        }
        System.out.println("Final array size = " + dates.length);
    }
}

1

一个良好的猜测是必要的;了解你的数据!

2

增长因子是任意的;2 是一个好的值,但会继续呈指数倍增。你可能想使用像 1.5 这样的因子,这意味着在低端会有更多的分配,但增长不会那么爆炸性。你需要以某种方式管理这个!

这种技术对于简单或相对较小的线性数据集合来说效果还不错。对于结构更为复杂的数据,你可能希望使用更动态的方法,就像 Recipe 7.4 中描述的那样。

7.3 集合框架

问题

你在跟踪所有这些列表、集合和迭代器时遇到了困难。

解决方案

这有一个模式。参见 Figure 7-1 和 Table 7-1。

Discussion

ListSetMapQueue 是集合框架的四种基本数据结构。ListSet 都是序列,区别在于 List 保留顺序并允许重复条目,而 Set 则不允许。Map 是一个键/值存储,也称为哈希、字典或关联存储。队列是,正如其名字所暗示的,你可以从一端推入,从另一端拉出的结构。

Table 7-1 显示了来自 java.util 包中一些重要的基于集合的类。由于空间限制,它故意不是 100% 完整的。

参见

CollectionsArraysListSet 及其实现类的 javadoc 文档提供了比这里提供的更多的细节。Table 7-1 可能会帮助你更好地吸收集合框架的规律性。

表 7-1. Java 集合

接口 实现
可调整大小的数组 散列表
--- ---
List ArrayList, Vector
Set
Map
Queue DequeBlockingQueue

Figure 7-1 显示了几种类型之间的关系。

jcb4 0701

图 7-1. 集合框架:矩形为接口;椭圆形为类;实线表示继承;虚线代表 implements

Queue 及其子类型在 Chapter 16 中有所描述。

7.4 像数组一样,但更动态

问题

你不想担心存储重新分配(通常是因为你不知道传入数据集的大小);你想要一个标准的类来为你处理它。你希望将数据存储在 第七章 中定义的任何 Collection 类中,具有类型安全性,而不必在从集合检索数据时编写 downcasts。

解决方案

使用List实现或其他Collections类,以及 Java 的泛型类型机制,声明Collection时加上类型参数以标识数据类型。类型参数名称在声明和实例化之后出现在尖括号中。

讨论

我们将讨论的第一个Collections类是ArrayList,它是来自java.util的标准类,封装了数组的功能,但允许它自动扩展。你可以不断地向其添加内容,每次添加的行为都是相同的。如果你非常仔细地观察,你可能会注意到在添加对象时偶尔会有一个短暂的额外暂停,这是因为ArrayList在重新分配和复制时发生了。但你无需去思考这些。

然而,由于ArrayList是一个类,并不是 Java 语法的一部分,所以你不能使用 Java 的数组语法;你必须使用方法来访问ArrayList的数据。它有添加对象、检索对象、查找对象以及告诉你List大小及其可以在不需重新分配的情况下变得多大的方法(注意,ArrayList类只是List接口的一种实现;稍后会详细介绍更多)。就像java.util中的其他集合类一样,ArrayList的存储和检索方法最初是定义为具有java.lang.Object的参数和返回值。由于Object是每个定义类型的祖先,你可以将任何类型的对象存储在List(或任何集合)中,并在检索时进行转换。如果你需要将少量的内建类型(如intfloat)存储到包含其他数据的集合中,请使用适当的包装类(参见第五章的介绍)。要存储boolean,可以直接存储在java.util.BitSet中(请参阅在线文档),或者使用Boolean包装类存储在List中。

因为Object通常对于准确的工作来说太过于普遍,所有现代版本的 Java 都提供了泛型机制。如今,你可以在尖括号中声明一个类型参数,比如声明一个ArrayList(或其他集合),编译器会将参数和返回值视为该类型,确保错误类型的对象不会进入你的集合,并避免在检索对象时写转换语句。例如,这是如何声明一个用于保存String对象引用的ArrayList

List<String> myList = new ArrayList<>();

尽管你正在定义(构造)它作为ArrayList,但将变量声明为接口类型List是一个良好的实践。这样做可以更容易地从一个List实现切换到另一个,并且避免意外依赖于接口中不存在的实现特定方法(这也会使得更改实现更加困难)。

在定义部分的<>是旧版 Java 的遗留物,旧版 Java 需要在类型定义中重复,因此在示例中你会写new ArrayList<String>()。现在只需使用<>(如示例中所示),表示你希望类型从声明中复制。<>称为钻石操作符

截至 Java 13,你可以通过使用新的var关键字(仅限局部变量)来简化:

var myList = new ArrayList<String>();

Table 7-2 展示了List接口的一些最重要的方法,这些方法由ArrayList和其他List实现类实现。这意味着旧的Vector类和其他实现类也可以使用完全相同的方法。你只需更改构造函数调用中使用的名称。

表 7-2。List<T>的常见方法

方法签名 用法
add(T o) 在末尾添加给定元素
add(int i, T o) 在指定位置插入给定的元素
clear() Collection中删除所有元素引用
contains(T o) 如果List包含给定对象,则返回 true
forEach(lambda) 对每个元素执行 lambda
get(int i) 返回指定位置的对象引用
indexOf(T o) 返回找到给定对象的索引,如果未找到则返回-1
of(T t, …) 从多个对象创建列表
remove(T o), remove(int i) 通过引用或位置删除对象
toArray() 返回包含Collection中对象的数组

ArrayListDemo将数据存储在ArrayList中,并检索进行处理:

public class ArrayListDemo {
    public static void main(String[] argv) {
        List<LocalDate> editions = new ArrayList<>();

        // Add lots of elements to the ArrayList...
        editions.add(LocalDate.of(2001, 06, 01));
        editions.add(LocalDate.of(2004, 06, 01));
        editions.add(LocalDate.of(2014, 06, 20));

        // Use old-style 'for' loop to get index number.
        System.out.println("Retrieving by index:");
        for (int i = 0; i<editions.size(); i++) {
            System.out.printf("Edition %d was %s\n", i + 1, editions.get(i));
        }
        // Use normal 'for' loop for simpler access
        System.out.println("Retrieving by Iterable:");
        for (LocalDate dt : editions) {
            System.out.println("Edition " + dt);
        }

    }
}

旧版的VectorHashtable类早于集合框架,因此它们提供了不同名称的额外方法:Vector提供了addElement()elementAt()。你可能仍然会在遗留代码中遇到这些,但应该使用Collectionadd()get()方法。另一个区别是Vector的方法是同步的,这意味着它们可以安全地从多个线程访问(参见 Recipe 16.5)。不过,这也意味着更多的开销,因此对于单线程访问,使用ArrayList速度更快(请参阅 Recipe 7.19 中的计时结果)。

有各种转换方法。Table 7-2 提到toArray(),它会将List的内容暴露为数组。Java 9+中的List接口具有静态的of()方法,可以在数组和List之间进行转换。结合现代 Java 的可变参数特性,你可以通过一次调用List.of()来创建和填充一个列表,例如:

List<String> firstNames = List.of("Robin", "Jaime", "Joey");

在旧应用程序和网络搜索中找到的遗留代码中,Arrays.asList()提供了这种功能,因此你可能会遇到类似以下的代码:

List<String> lastNames = Arrays.asList("Smith", "Jones", "MacKenzie");
// or even
List<String> lastNames =
    Arrays.asList(new String[]{"Smith", "Jones", "MacKenzie"});

Java 随着时间的推移确实变得不那么冗长!

您仍然可以实例化诸如ArrayList之类的类而不使用特定类型。在这种情况下,您将得到一个编译器警告,并且类将像以往一样运行;也就是说,从CollectionIterator返回的对象将是java.lang.Object类型,必须在调用任何类特定方法或在任何应用特定方法调用之前进行向下转型。

作为进一步的示例,考虑在第 7 章中提到的Map接口。Map在其put()方法中需要一个键和一个值。因此,Map具有两个参数化类型。要设置一个Map,其键是Person对象,值是Address对象(假设这两个类在您的应用程序中存在),可以像这样定义它:

Map<Person, Address> addressMap = new HashMap<>();

这个Map期望其put()方法的键是Person,值是Addressget()方法返回一个Address对象,keySet()方法返回Set<Person>(即专门用于Person对象的Set)。当您想要从现有对象创建一个Map时,还有方便的例程。最有用的是与List.of()类似的几个重载版本的已经存在的Map.of(key,value,key,value…),以及其他功能。

参见

尽管泛型避免了您必须编写向下转型,但转型仍然发生在运行时;它们只是由编译器提供的。编译器在编译这些新构造时使用的技术包括擦除桥接,这些主题在《Java 泛型与集合》(http://shop.oreilly.com/product/9780596527754.do)中有讨论,作者是莫里斯·纳夫特林和菲利普·沃德勒。

7.5 在自己的类中使用泛型类型

问题

您希望使用泛型类型机制定义自己的容器类,以避免不必要的转型。

解决方案

使用< TypeName >定义一个类,其中声明了容器类型,使用*TypeName*来使用它。

讨论

考虑在示例 7-1 中的非常简单的Stack类。(我们在配方 7.16 中讨论了堆栈类的性质和用途。)

此版本已经参数化为接受一个名为T的类型。这种类型T将是push()方法参数的类型,pop()方法返回类型等。由于这个返回类型比原始集合的Object返回类型更具体,从pop()返回的值不需要进行向下转型。集合框架(java.util)中的所有容器都类似地进行了参数化。

示例 7-1. main/src/main/java/structure/MyStack.java
public class MyStack<T> implements SimpleStack<T> {

    private int depth = 0;
    public static final int DEFAULT_INITIAL = 10;
    private T[] stack;

    public MyStack() {
        this(DEFAULT_INITIAL);
    }

    public MyStack(int howBig) {
        if (howBig <= 0) {
            throw new IllegalArgumentException(
            howBig + " must be positive, but was " + howBig);
        }
        stack = (T[])new Object[howBig];
    }

    @Override
    public boolean empty() {
        return depth == 0;
    }

    /** push - add an element onto the stack */
    @Override
    public void push(T obj) {
        // Could check capacity and expand
        stack[depth++] = obj;
    }

    /* pop - return and remove the top element */
    @Override
    public T pop() {
        --depth;
        T tmp = stack[depth];
        stack[depth] = null;
        return tmp;
    }

    /** peek - return the top element but don't remove it */
    @Override
    public T peek() {
        if (depth == 0) {
            return null;
        }
        return stack[depth-1];
    }

    public boolean hasNext() {
        return depth > 0;
    }

    public boolean hasRoom() {
        return depth < stack.length;
    }

    public int getStackDepth() {
        return depth;
    }
}

特定类型的关联是在实例化类时完成的。例如,要实例化一个专门用于持有BankAccount对象的MyStack,您只需要编写以下代码:

MyStack<BankAccount> theAccounts = new MyStack<>( );

如果您不提供类型参数 T,则这个集合(如 java.util 中的集合)将表现得像在泛型集合出现之前的那些日子一样——接受任何类型的输入参数,从 getter 方法返回 java.lang.Object,并需要向下转型——作为它们默认的向后兼容行为。例子 7-2 展示了一个创建两个 MyStack 实例的程序,一个专门用于 String,另一个保持一般化。通用的那个,称为 ms2,加载了与 ms1 相同的两个 String 对象,但还包括一个 Date 对象。现在打印代码已经失效,因为它将抛出 ClassCastExceptionDate 不是 String。出于学术目的,我特别处理了这种情况:这说明了在使用非参数化容器类时可能遇到的错误类型。

示例 7-2. main/src/main/java/structure/MyStackDemo.java
public class MyStackDemo {

    @SuppressWarnings({"rawtypes","unchecked"})
    public static void main(String[] args) {
        MyStack<String> ms1 = new MyStack<>();
        ms1.push("billg");
        ms1.push("scottm");

        while (ms1.hasNext()) {
            String name = ms1.pop();
            System.out.println(name);
        }

        // Old way of using Collections: not type safe.
        // DO NOT GENERICIZE THIS
        MyStack ms2 = new MyStack();
        ms2.push("billg");               // EXPECT WARNING
        ms2.push("scottm");              // EXPECT WARNING
        ms2.push(new java.util.Date());  // EXPECT WARNING

        // Show that it is broken
        try {
            String bad = (String)ms2.pop();
            System.err.println("Didn't get expected exception, popped " + bad);
        } catch (ClassCastException ex) {
            System.out.println("Did get expected exception.");
        }

        // Removed the brokenness, print rest of it.
        while (ms2.hasNext()) {
            String name = (String)ms2.pop();
            System.out.println(name);
        }
    }
}

由于这种潜在的错误可能性,编译器会警告您存在未检查的原始类型。与第 1.9 节讨论的弃用警告类似,默认情况下,javac 编译器不会详细打印这些警告(它们将出现在大多数集成开发环境中)。您可以使用相当冗长的选项 -Xlint:unchecked 请求它们:

C:> javac -source 1.5 structure/MyStackDemo.java
Note: MyStackDemo.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
C:> javac -source 1.5 -Xlint:unchecked structure/MyStackDemo.java
MyStackDemo.java:14: warning: unchecked call to push(T) as a member of the raw
type MyStack
                ms2.push("billg");
                   ^
MyStackDemo.java:15: warning: unchecked call to push(T) as a member of the raw
type MyStack
                ms2.push("scottm");
                   ^
MyStackDemo.java:16: warning: unchecked call to push(T) as a member of the raw
type MyStack
                ms2.push(new java.util.Date( ));
                   ^
3 warnings
C:>

我在第 7.16 节中更详细地讨论了 MyStack 的开发和演变。

7.6 我应该如何迭代你?让我列举一下方法

问题

需要迭代一些结构化数据。

解决方案

Java 提供了许多迭代数据集合的方法。以下按照最新的顺序列出:

  • Stream.forEach() 方法(Java 8)

  • Iterable.forEach() 方法(Java 8)

  • Java “foreach” 循环(Java 5)

  • java.util.Iterator(Java 2)

  • 三部分 for 循环

  • while 循环 * 枚举

选择一个并使用它。或者学习它们并保存!

讨论

这里对每种迭代方法都简要说明了一些词。请注意,前几种是最常见的。

Stream.forEach 方法(Java 8)

作为 Java 函数式编程的一部分引入的 Stream 机制提供了两种最近的迭代方式之一,Stream.forEach(),在第 9.3 节中有讨论。现在,这里有一个快速的例子,使用 BufferedReaderlines() 方法返回一个 Stream

$ jshell
jshell> import java.io.*;
jshell> BufferedReader is =
  new BufferedReader(new FileReader("/home/ian/.profile"));
is ==> java.io.BufferedReader@58651fd0
jshell> is.lines().forEach(System.out::println)
... prints the lines of the file ...

Iterable.forEach 方法(Java 8)

Iterable.forEach() 方法是最近新增的迭代技术之一,Java 8 中引入。该方法可以在任何 Iterable 上调用(不幸的是,数组类尚未实现 Iterable),接受一个实现了函数接口 java.util.function.Consumer 的参数。关于函数接口的讨论见第九章,以下是一个例子:

public class IterableForEach {

    public static void main(String[] args) {
        Collection<String> c =                    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
                List.of("One", "Two", "Three");   ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        c.forEach(s -> System.out.println(s));    ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
    }
}

1

声明一个 CollectionCollectionIterable)。

2

使用 Arrays.of() 将数组或对象序列填充进去(参见 Recipe 7.4,了解任意参数列表如何成为数组)。

3

调用集合的 forEach() 方法,传递一个 lambda 表达式(参见第九章,了解 sSystem.out.println(s) 如何映射到 Consumer 接口实现,而不需要显式导入该接口)。

这种迭代方式——有时被称为 内部迭代——颠覆了传统 for 循环的控制方式;集合负责迭代的时间和方式。

提示

Stream.forEachIterable.forEach() 都接受一个类型为 java.util.function.Consumer 的参数,因此它们在语法上基本相同。这是有意为之。

Java “foreach” 循环(Java 5)

这是 for-each 循环的语法:

for (Type var : Iterable<Type>) {
	// do something with "var"
}

for-each 循环可能是现代 Java 代码中最常见的循环风格。Iterable 可以是数组或任何实现了 Iterable 的东西(包括 Collection 实现)。

本书中广泛采用这种风格。此外,许多第三方框架/库提供自己的类型来实现 Iterable,以便与 for 循环一起使用。

java.util.Iterator(Java 2)

旧的 Iterator 接口有三种方法:

public interface java.util.Iterator<E> {
  public abstract boolean hasNext();
  public abstract E next();
  public default void remove();
}

曾经常见的代码编写方式如下,您仍然可能在旧代码中找到:

Iterator it = ...; // legacy code; might not even have type parameter
while (it.hasNext()) {
	(MyDataType) c = it.next();
	// Do something with c
}

如果在只读集合上调用 remove() 方法,则会抛出 UnsupportedOperationException。与 Stream 和默认方法结合使用,现在有第四种方法:

public default void forEachRemaining(java.util.function.Consumer<? super E>);

三部分 for 循环

这是 Dennis Ritchie 在 1970 年代早期为 C 语言发明的传统 for 循环:

for (init; test; change) {
	// do something
}

其最常见的形式是使用一个 int “索引变量”或“循环变量”:

MyDataType[] data = ...
for (int i = 0; i < data.length; i++)
	MyDataType d = data[i];
	// do something with it
}

while 循环

while 循环会在测试条件为真时执行其循环体。通常与 EnumerationIterator 结合使用,例如:

Iterator<MyData> iterator = ...
while (iterator.hasNext()) {
	MyData md = iterator.next();
	//
}

枚举

Enumeration 类似于之前展示的 Iterator,但它缺少 remove() 方法,控制方法的名称更长,例如 hasMore​Ele⁠ments()nextElement()。对于新代码,建议尽量避免实现 Enumeration

7.7 使用 Set 避免重复

问题

您希望一个结构,可以避免存储重复项。

解决方案

使用 Set 实现而不是 List(例如 Set<String> myNames = new HashSet<>())。

讨论

Set 接口类似于 List 接口,² 其方法包括 add()remove()contains()size()isEmpty()。不同之处在于它不保留顺序;相反,它强制唯一性——如果多次添加相同的项(根据其 equals() 方法),它将在集合中仅出现一次。因此,索引为基础的方法如 add(int, Object)get(int)Set 实现中是缺失的:你可能知道你添加了七个对象,但其中只有五个是唯一的,因此调用 get() 来检索第六个对象将会抛出 ArrayIndexOutOfBoundsException 异常!最好不要将 Set 视为具有索引的集合。

警告

正如 Java 7 中的 Set 文档所述:“注意:如果将可变对象用作集合元素,则必须非常小心。如果以影响等式比较的方式更改对象的值,则集合的行为未指定。此禁止的特殊情况是,集合不能包含自身作为元素。”

此代码展示了向 Set 中添加重复条目的情况,它将只包含一个字符串 "One" 的副本:

        Set<String> hashSet = new HashSet<>();
        hashSet.add("One");
        hashSet.add("Two");
        hashSet.add("One"); // DUPLICATE
        hashSet.add("Three");
        hashSet.forEach(s -> System.out.println(s));

不出所料,只打印了三个不同的值。

如果需要排序的 Set,实际上有一个 SortedSet 接口,其中最常见的实现是 TreeSet;查看 Recipe 7.12 中的 TreeSet 示例。

List 一样,Set 接口从 Java 9 开始提供了 of 方法:

Set<Double> nums = Set.of(Math.PI, 22D/7, Math.E);
Set<String> firstNames = Set.of("Robin", "Jaime", "Joey");

7.8 在链表中结构化数据

问题

你的数据不适合在数组中使用。

解决方案

使用链表;Java 的 LinkedList 类非常适合。

讨论

任何参加过计算机科学 101(或任何计算机科学课程)的人都应该熟悉数据结构,如链表和二叉树。当你有一个不可预测的大量数据项,希望分配恰到好处的存储空间,并且希望按照创建顺序访问它们时,通常使用链表。图 7-2 是显示正常排列的图表。

jcb4 0702

图 7-2. 链表结构

当然,Collections API 提供了 LinkedList 类;这里是一个使用它的简单程序:

public class LinkedListDemo {
    public static void main(String[] argv) {
        System.out.println("Here is a demo of Java's LinkedList class");
        LinkedList<String> l = new LinkedList<>();
        l.add(new Object().toString());
        l.add("Hello");
        l.add("end of the list");

        System.out.println("Here is a list of all the elements");
        l.forEach(o ->
            System.out.println("Next element: " + o));

        if (l.indexOf("Hello") < 0)
            System.err.println("Lookup does not work");
        else
            System.err.println("Lookup works");

        // Now, for added fun, let's walk the linked list backwards.
        ListIterator<String> li = l.listIterator();
        while (li.hasPrevious()) {
            System.out.println("Back to: " + li.previous());
        }
    }
}

此处使用的 ListIteratorIterator 的子接口,如 Recipe 7.6 所述。

为了展示这种列表的工作方式,这里是显示简单链表实现的部分代码:

public class LinkList<T> implements List<T> {

    /* A TNode stores one node or item in a Linked List */
    private static class TNode<T> {
        private TNode<T> next;
        private T data;
        TNode(T o, TNode<T> next) {
            data = o;
            this.next = next;
        }
        @Override
        public String toString() {
            return String.format("TNode: data='%s', next='%d'", data,
                    next == null ? 0 : next.hashCode());
        }
    }

    private boolean DIAGNOSTIC = false;

    /** The root or first TNode in the list; is a dummy pointer,
 * so its data will always be null. Simpler this way.
 */
    protected TNode<T> first;
    /**
 * For certain optimizations: A second ref to the last TNode in the list;
 * initially == first; always valid (never null), always has next == null.
 */
    protected TNode<T> last;

    /** Construct a LinkList: initialize the first and last nodes */
    public LinkList() {
        clear();
    }

    /** Construct a LinkList given another Collection.
 * This method is recommended by the general contract of List.
 */
    public LinkList(Collection<T> c) {
        this();
        addAll(c);
    }

    /** Set the List (back) to its initial state.
 * Any references held will be discarded.
 */
    @Override
    public void clear() {
        first = new TNode<T>(null, null);
        last = first;
    }

    /** Add one object to the end of the list. Update the "next"
 * reference in the previous end, to refer to the new node.
 * Update "last" to refer to the new node.
 */
    @Override
    public boolean add(T o) {
        last.next = new TNode<T>(o, null);
        last = last.next;
        return true;
    }

    @Override
    public void add(int where, T o) {
        TNode<T> t = first;
        for (int i=0; i<=where; i++) {
            t = t.next;
            if (t == null) {
                throw new IndexOutOfBoundsException(
                    "'add(n,T) went off end of list");
            }
            if (DIAGNOSTIC) {
                System.out.printf("in add(int,T): i = %d, t = %s%n", i, t);
            }
        }
        if (DIAGNOSTIC) {
            System.out.printf("in add(int,T): to insert before %s\n", t);
        }
        final TNode<T> nn = new TNode<>(o, t.next);
        t.next = nn;
        if (DIAGNOSTIC) {
            System.out.printf("add(%d,%s)\n", where, o);
            dump("add(int,T)");
        }
    }

    @Override
    public boolean addAll(Collection<? extends T> c) {
        c.forEach(o -> add((T) o));
        return false;
    }

    @Override
    public boolean addAll(int i, Collection<? extends T> c) {
        AtomicInteger j = new AtomicInteger(i);
        c.forEach(o -> { add(j.getAndIncrement(), o); });
        return true;
    }

    @Override
    public boolean contains(Object o) {
        TNode<T> t = first;
        while ((t = t.next) != null) {
            if (t.data.equals(o)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public T get(int where) {
        TNode<T> t = first;
        int i=0;
        // If we get to the end of list before 'where', error out
        while (i++<=where) {
            if (t.next == null) {
                throw new IndexOutOfBoundsException();
            }
            t = t.next;
        }
        return t.data;
    }

    @Override
    public boolean isEmpty() {
        return first == last;
    }

    public Iterator<T> iterator() {
        return new Iterator<T>() {
            final int size = size();
            int n = 0;
            TNode<T> t = first;
            /**
 * Two cases in which next == null:
 * 1) The list is empty, we are at first
 * 2) The list is not empty, we are at last.
 */
            public boolean hasNext() {
                return n < size;
            }

            public T next() {
                if (t == first) {
                    t = t.next;
                }
                TNode<T> result = t;
                t = t.next;
                ++n;
                return result.data;
            }
            public void remove() {
                throw new UnsupportedOperationException("remove");
            }
        };
    }

    @Override
    public boolean remove(Object o) {
        TNode<T> p = first, prev = null;
        while (p != null) {
            if (p.data == o) {
                prev.next = p.next;
                return true;
            }
            prev = p; p = p.next;
        }
        return false;
    }

    @Override
    public T set(int i, T o) {
        TNode<T> tmp = find(i);
        tmp.data = o;
        return o;
    }

    @Override
    public int size() {
        TNode<T> t = first;
        int i;
        for (i=0; ; i++) {
            if (t == null)
                break;
            t = t.next;
        }
        return i - 1;    // subtract one for mandatory head node
    }

    @SuppressWarnings("unchecked")
    public T[] toArray(Object[] data) {
        // First is an empty anchor, start at its next
        TNode<T> p = first.next;
        for (int i = 0; p != null && i < data.length; i++) {
            data[i] = p.data;
            p = p.next;
        }
        return (T[]) data;
    }

    public Object[] toArray() {
        Object[] data = new Object[size()];
        return toArray(data);
    }
警告

这只是展示链表实现可能的方式。不要使用此处显示的简单 LinkList 类;请使用真正的 java.util.LinkedList,如第一个示例中展示的那样。

7.9 使用 Hashtable 和 HashMap 进行映射

问题

你需要一个从一个数据项到另一个数据项的单向映射。

解决方案

使用 HashMap

讨论

HashMap 提供了一种从一组对象引用到另一组对象引用的单向映射。它们完全是通用的。我用它们来映射从 Swing 按钮到按钮被按下时要打开的 URL,将名称映射到地址,以及在 Web 服务器中实现一个简单的内存缓存。您可以将任何东西映射到任何东西。在下面的示例中,我们将公司名称映射到地址;这里的地址是 String 对象,但在实际生活中它们可能是 Address 对象:

public class HashMapDemo {

    public static void main(String[] argv) {

        // Construct and load the hash. This simulates loading a
        // database or reading from a file, or wherever the data is.

        Map<String,String> map = new HashMap<String,String>();

        // The hash maps from company name to address.
        // In real life this might map to an Address object...
        map.put("Adobe", "Mountain View, CA");
        map.put("IBM", "White Plains, NY");
        map.put("Learning Tree", "Los Angeles, CA");
        map.put("Microsoft", "Redmond, WA");
        map.put("Netscape", "Mountain View, CA");
        map.put("O'Reilly", "Sebastopol, CA");
        map.put("Sun", "Mountain View, CA");

        // Two versions of the "retrieval" phase.
        // Version 1: get one pair's value given its key
        // (presumably the key would really come from user input):
        String queryString = "O'Reilly";
        System.out.println("You asked about " + queryString + ".");
        String resultString = map.get(queryString);
        System.out.println("They are located in: " + resultString);
        System.out.println();

        // Version 2: get ALL the keys and values
        // (maybe to print a report, or to save to disk)
        for( String key : map.keySet()) {
            System.out.println("Key " + key +
                "; Value " + map.get(key));
        }

        // Version 3: Same but using a Map.Entry lambda
        map.entrySet().forEach(mE ->
            System.out.println("Key + " + mE.getKey()+
                "; Value " +mE.getValue()));
    }
}

对于这个版本,我们既使用了 for 循环,也使用了 forEach() 循环;后者使用 entrySet() 的返回值,即包含一个键和一个值的 Map.Entry 集合(在大型映射上可能更快,因为它避免了每次循环时重新进入映射获取值)。如果您在循环内或在另一个线程中修改列表(例如,删除元素),那么这些形式将会因为 ConcurrentModificationException 而失败。然后,您需要显式使用 Iterator 来控制循环:

        // Version 2: get ALL the keys and values
        // with concurrent modification
        Iterator<String> it = map.keySet().iterator();
        while (it.hasNext()) {
            String key = it.next();
            if (key.equals("Sun") || key.equals("Netscape")) {
                it.remove();
                continue;
            }
            System.out.println("Company " + key + "; " +
                "Address " + map.get(key));
        }

一个更为实用的(参见第九章)编写移除操作的方法,不涉及显式循环,可以是这样的:

        // Alternate to just do the removals, without explicit looping
        map.keySet().removeIf(key -> Set.of("Netscape", "Sun").contains(key));
        // or
        map .entrySet()
            .removeIf(entry -> Set.of("Netscape", "Sun")
            .contains(entry.getKey()));
        map.entrySet().forEach(System.out::println);
提示

HashMap 方法不是同步的。旧的类似 Hashtable 的方法是同步的,适用于多线程。

7.10 在属性和偏好中存储字符串

问题

您需要存储的键和值都是字符串,可能会跨程序运行保持不变,例如程序定制。

解决方案

使用 java.util.prefs.Preferences 对象或 java.util.Properties 对象。

讨论

下面是基于用户环境的三种定制方法。Java 提供了 PreferencesProperties 用于跨平台定制。

偏好

Preferencesjava.util.prefs.Preferences 提供了一种易于使用的机制,用于以系统相关方式存储用户定制(这可能意味着在 Unix 上是点文件,在 Mac 上是偏好文件,在 Windows 系统上是注册表)。该类提供了一组表示用户偏好的节点层次结构。数据以系统相关的存储格式存储,但也可以导出到或导入自 XML 格式。下面是 Preferences 的一个简单演示:

public class PrefsDemo {

    public static void main(String[] args) throws Exception {

        // Set up the Preferences for this application, by class.
        Preferences prefs = Preferences.userNodeForPackage(PrefsDemo.class);

        // Retrieve some preferences previously stored, with defaults in case
        // this is the first run.
        String text    = prefs.get("textFontName", "lucida-bright");
        String display = prefs.get("displayFontName", "lucida-blackletter");
        System.out.println(text);
        System.out.println(display);

        // Assume the user chose new preference values: Store them back.
        prefs.put("textFontName", "times-roman");
        prefs.put("displayFontName", "helvetica");

        // Toss in a couple more values for the curious who want to look
        // at how Preferences values are actually stored.
        Preferences child = prefs.node("a/b");
        child.putInt("meaning", 42);
        child.putDouble("pi", Math.PI);

        // And dump the subtree from our first node on down, in XML.
        prefs.exportSubtree(System.out);
    }
}

当您第一次运行 PrefsDemo 程序时,当然,它找不到任何设置,所以对 preferences.get() 的调用将返回默认值:

$ java -cp target/classes structure.PrefsDemo
lucida-bright
lucida-blackletter
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE preferences SYSTEM "http://java.sun.com/dtd/preferences.dtd">
<preferences EXTERNAL_XML_VERSION="1.0">
  <root type="user">
    <map/>
    <node name="structure">
      <map>
        <entry key="displayFontName" value="helvetica"/>
        <entry key="textFontName" value="times-roman"/>
      </map>
      <node name="a">
        <map/>
        <node name="b">
          <map>
            <entry key="meaning" value="42"/>
            <entry key="pi" value="3.141592653589793"/>
          </map>
        </node>
      </node>
    </node>
  </root>
</preferences>

在后续运行中,它会找到并返回用户提供的设置(我省略了第二次运行的 XML 输出,因为大部分 XML 输出是相同的):

> java structure.PrefsDemo
times-roman
helvetica
...
>

属性

Properties类类似于HashMapHashtable(它扩展了后者),但定义了专门用于字符串存储和检索以及加载/保存的方法。Properties对象在 Java 中被广泛使用,从设置平台字体名称到在国际化和本地化的一部分中将用户应用程序定制为不同的Locale设置。当存储在磁盘上时,Properties对象看起来就像一系列的name=value赋值,带有可选的注释。编辑Properties文件时会添加注释,Properties对象读取自身时会忽略注释,并且在要求Properties对象将自身保存到磁盘时会丢失注释。这里是一个可用于在基于 GUI 的程序中国际化菜单的Properties文件示例:

# Default properties for MenuIntl
program.title=Demonstrate I18N (MenuIntl)
program.message=Welcome to an English-localized Java Program
#
# The File Menu
#
file.label=File Menu
file.new.label=New File
file.new.key=N
file.open.label=Open...
file.open.key=O
file.save.label=Save
file.save.key=S
file.exit.label=Exit
file.exit.key=Q

这里是另一个示例,显示了一些个性化属性:

name=Ian Darwin
favorite_popsicle=cherry
favorite_rock group=Fleetwood Mac
favorite_programming_language=Java
pencil_color=green

可以从文件加载Properties对象。规则很灵活:可以在键名和其值后使用=:或空格。在键中忽略非空格字符后的空格。反斜杠可用于续行或转义其他字符。注释行可以以#!开头。因此,如果手工准备的Properties文件包含前述项目,则可能如下所示:

# Here is a list of properties
! first, my name
name Ian Darwin
favorite_popsicle = cherry
favorite_rock\ group \
 Fleetwood Mac
favorite_programming_language=Java
pencil_color green

幸运的是,当Properties对象将自身写入文件时,它使用以下简单格式:

key=value

这里是一个创建Properties对象并将其中列出的公司及其位置从Recipe 7.9添加到其中的程序示例。然后从磁盘加载附加属性。为简化 I/O 处理,程序假定要加载的Properties文件包含在标准输入中,就像在 Unix 或 DOS 上使用命令行重定向一样:

public class PropsCompanies {

    public static void main(String[] argv) throws java.io.IOException {

        Properties props = new Properties();

        // Get my data
        props.put("Adobe", "Mountain View, CA");
        props.put("IBM", "White Plains, NY");
        props.put("Learning Tree", "Los Angeles, CA");
        props.put("Microsoft", "Redmond, WA");
        props.put("Netscape", "Mountain View, CA");
        props.put("O'Reilly", "Sebastopol, CA");
        props.put("Sun", "Mountain View, CA");

        // Now load additional properties
        props.load(System.in);

        // List merged properties, using System.out
        props.list(System.out);
    }
}

将其运行为

java structure.PropsCompanies < PropsDemo.out

将以下输出产生到文件PropsDemo.out中:

-- listing properties --
Sony=Japan
Sun=Mountain View, CA
IBM=White Plains, NY
Netscape=Mountain View, CA
Nippon_Kogaku=Japan
Acorn=United Kingdom
Adobe=Mountain View, CA
Ericsson=Sweden
O'Reilly & Associates=Sebastopol, CA
Learning Tree=Los Angeles, CA

如果您在HashMapProperties示例中没有注意到,这些示例中输出出现的顺序既不排序也不按我们放置它们的顺序。哈希类和Properties子类对检索对象的顺序不做任何声明。如果需要它们排序,请参阅Recipe 7.11

作为方便的快捷方式,我的FileProperties类包括一个接受文件名的构造函数:

import com.darwinsys.util.FileProperties;
...
Properties p = new FileProperties("PropsDemo.out");

请注意,构造FileProperties对象会导致其被加载,因此构造函数可能抛出IOException类的已检查异常。

7.11 对集合进行排序

问题

您将数据以随机顺序放入集合中或使用不保留顺序的Properties对象,现在您希望对其进行排序。

解决方案

使用静态方法Arrays.sort()Collections.sort(),可选提供Comparator

讨论

如果你的数据在一个数组中,那么你可以使用Arrays实用类的静态sort()方法对其进行排序。如果它在一个Collection中,你可以使用Collections类的静态sort()方法。这里是一个字符串集合在Array中原地排序的示例:

public class SortArray {
    public static void main(String[] unused) {
        String[] strings = {
            "painful",
            "mainly",
            "gaining",
            "raindrops"
        };
        Arrays.sort(strings);
        for (int i=0; i<strings.length; i++) {
            System.out.println(strings[i]);
        }
    }
}

如果默认排序顺序不是你想要的呢?那么,你可以创建一个实现了Comparator<T>接口的对象,并将其作为第二个参数传递给sort方法。幸运的是,对于除默认排序外最常见的排序,你不需要这样做:可以将String.CASE_INSENSITIVE_ORDER作为第二个参数传递。String类将其定义为一个Comparator<String>,按compareToIgnoreCase方法对String对象排序。但如果你需要更复杂的排序,可能需要编写一个Comparator<T>。在某些情况下,你可以使用Comparator.comparing()方法和Comparator的其他静态方法来创建自定义比较器,而不必创建一个类。假设出于某种奇怪的原因,你需要使用除了字符串的第一个字符之外的所有字符进行排序。可以编写以下Comparator<String>来实现:

/** Comparator for comparing strings ignoring first character.
 */
public class SubstringComparator implements Comparator<String> {
    @Override
    public int compare(String s1, String s2) {
        s1 = s1.substring(1);
        s2 = s2.substring(1);
        return s1.compareTo(s2);
        // or, more concisely:
        // return s1.substring(1).compareTo(s2.substring(1));
    }
}

使用它只需要将其作为sort()Comparator参数传递即可,如下所示:

public class SubstringComparatorDemo {
    public static void main(String[] unused) {
        String[] strings = {
            "painful",
            "mainly",
            "gaining",
            "raindrops"
        };
        Arrays.sort(strings);
        dump(strings, "Using Default Sort");
        Arrays.sort(strings, new SubstringComparator());
        dump(strings, "Using SubstringComparator");

        // tag::functional[]
        System.out.println("Functional approach:");
        Arrays.stream(strings)
            .sorted(Comparator.comparing(s->s.substring(1)))
            .forEach(System.out::println);
        // end::functional[]
    }

    static void dump(String[] args, String title) {
        System.out.println(title);
        for (String s : args)
            System.out.println(s);
    }
}

再次,一个更为函数式的(见第九章)编写方式可能如下所示:

        System.out.println("Functional approach:");
        Arrays.stream(strings)
            .sorted(Comparator.comparing(s->s.substring(1)))
            .forEach(System.out::println);

这是运行它的输出:

$ java structure.SubstrCompDemo
Using Default Sort
gaining
mainly
painful
raindrops
Using SubstringComparator
raindrops
painful
gaining
mainly

这一切都应该如此。

另一方面,你可能正在编写一个类,并希望内置比较功能,这样就不必每次都记得传递Comparator。在这种情况下,你可以直接实现java.lang.Comparable接口,就像标准 API 中的许多类所做的那样。这些类包括String类;包装类ByteCharacterDoubleFloatLongShortIntegerjava.math中的BigIntegerBigDecimaljava.time中日期/时间 API 中的大多数对象;以及java.text.CollationKey。可以对这些类型的数组或Collections进行排序,而无需提供Comparator。实现Comparable接口的类被称为具有自然顺序。文档强烈建议类的自然顺序与其equals()方法一致。如果e1.compareTo((Object)e2)的布尔值与e1.equals((Object)e2)对于给定类的每个实例e1e2具有相同的布尔值,则它与equals()一致。这意味着如果你实现了Comparable,你也应该实现equals(),并且equals()的逻辑应该与compareTo()方法的逻辑一致。如果你实现了equals(),顺便说一句,你也应该实现hashCode()(如在“hashCode() and equals()”中讨论的那样)。例如,这是一个假设调度程序中的约会类Appt的一部分。该类有一个LocalDate日期变量和一个LocalTime时间变量;后者可能为空(例如全天约会或待办事项),这会稍微复杂化compareTo()函数。

// public class Appt implements Comparable {
    // Much code and variables omitted - see online version
    //-----------------------------------------------------------------
    //    METHODS - COMPARISON
    //-----------------------------------------------------------------
    /** compareTo method, from Comparable interface.
 * Compare this Appointment against another, for purposes of sorting.
 * <P>Only date and time, then text, participate, not repetition!
 * (Repetition has to do with recurring events, e.g.,
 *  "Meeting every Tuesday at 9").
 * This methods is consistent with equals().
 * @return -1 if this<a2, +1 if this>a2, else 0.
 */
    @Override
    public int compareTo(Appt a2) {
        // If dates not same, trigger on their comparison
        int dateComp = date.compareTo(a2.date);
        if (dateComp != 0)
            return dateComp;
        // Same date. If times not same, trigger on their comparison
        if (time != null && a2.time != null) {
            // Neither time is null
            int timeComp = time.compareTo(a2.time);
            if (timeComp != 0)
                return timeComp;
        } else /* At least one time is null */ {
            if (time == null && a2.time != null) {
                return -1; // All-day appts sort low to appear first
            } else if (time != null && a2.time == null)
                return +1;
                // else both have no time set, so carry on
        }
        // Same date & time, trigger on text
        return text.compareTo(a2.text);
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((date == null) ? 0 : date.hashCode());
        result = prime * result + ((text == null) ? 0 : text.hashCode());
        result = prime * result + ((time == null) ? 0 : time.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object o2) {
        if (this == o2)
            return true;
        if (o2.getClass() != Appt.class)
            return false;
        Appt a2 = (Appt) o2;
        if (!date.equals(a2.date))
            return false;
        if (time != null && !time.equals(a2.time))
            return false;
        return text.equals(a2.text);
    }

    /** Return a String representation of this Appt.
 * Output is intended for debugging, not presentation!
 */
    @Override
    public String toString() {
        var sb = new StringBuilder();
        sb.append(date).append(' ');
        if (time != null) {
            sb.append(time.getHour())
            .append(':')
            .append(time.getMinute())
            .append(' ');
        } else {
            sb.append("(All day)").append(' ');
        }
        sb.append(text).toString();
        return sb.toString();
    }

如果你仍然困惑于ComparableComparator之间的区别,你可能并不孤单。表 7-3 总结了这两个比较接口。

表 7-3. ComparableComparator 的比较

接口名称 描述 方法
java.lang.Comparable<T> 为对象提供自然排序。写在正在排序其对象的类中。 int compareTo(T o);
java.util.Comparator<T> 提供对另一个类的对象进行排序的完全控制。独立的策略对象;传递给sort()方法或Collection构造函数。 int compare(T o1, T o2); boolean equals(T c2)

7.12 避免排序的冲动

问题

你的数据需要排序,但你不想停下来周期性地进行排序。

解决方案

并非所有需要排序的东西都需要显式的排序操作。只需始终保持数据有序即可。

讨论

通过确保数据始终按正确顺序排列,你可以避免显式排序操作的开销和经过时间,尽管这可能在总体上是否更快取决于你的数据以及你选择如何保持其排序。你可以通过手动方式或者使用TreeSet或者TreeMap来保持其排序。首先,这里有一些来自我在 Java 首次公开发布时编写的呼叫跟踪程序的代码(代码已稍作现代化处理!),用于跟踪我与之有长时间接触的人员。比转盘名片座机功能少得多,我的CallTrack程序维护了一个按姓和名排序的人员列表。它还包括每个人的城市、电话号码和电子邮件地址。这是围绕“新用户”按钮的事件处理的一小部分代码:

public class CallTrack {

    /** The list of Person objects. */
    protected List<Person> usrList = new ArrayList<>();

    /** The scrolling list */
    protected java.awt.List visList = new java.awt.List();

    /** Add one (new) Person to the list, keeping the list sorted. */
    protected void add(Person p) {
        String lastName = p.getLastName();
        int i;
        // Find in "i" the position in the list where to insert this person
        for (i=0; i<usrList.size(); i++)
            if (lastName.compareTo((usrList.get(i)).getLastName()) <= 0)
                break; // If we don't break, OK, will insert at end of list.
        usrList.add(i, p);

        // Now insert them in the scrolling list, in the same position.
        visList.add(p.getFullName(), i);
        visList.select(i);      // ensure current
    }

}

此代码使用String类的compareTo(String)例程。

警告

此代码使用线性搜索,对于原始应用程序来说是可以的,但在大型列表上可能变得非常缓慢(它是O(n))。对于大型列表,你需要使用哈希或二进制搜索来查找放置值的位置。

如果我今天编写这段代码,我可能会使用TreeSet(保持对象顺序)或者TreeMap(保持键顺序并映射到值;键将是姓名,值将是Person对象)。两者都将对象插入树中的正确顺序,因此遍历树的Iterator始终按排序顺序返回对象。此外,它们还有诸如headSet()headMap()等方法,返回同一类的新的SetMap,其中包含在给定值之前的对象。类似地,tailSet()tailMap()方法返回大于给定值的对象,而subSet()subMap()返回一个范围。first()last()方法从集合中获取明显的组件。以下程序使用TreeSet对一些姓名进行排序:

        // A TreeSet keeps objects in sorted order. Use a Comparator
        // published by String for case-insensitive sorting order.
        TreeSet<String> theSet = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        theSet.add("Gosling");
        theSet.add("da Vinci");
        theSet.add("van Gogh");
        theSet.add("Java To Go");
        theSet.add("Vanguard");
        theSet.add("Darwin");
        theSet.add("Darwin");    // TreeSet is Set, ignores duplicates.

        System.out.printf("Our set contains %d elements", theSet.size());

        // Since it is sorted we can easily get various subsets
        System.out.println("Lowest (alphabetically) is " + theSet.first());

        // Print how many elements are greater than "k"
        // Should be 2 - "van Gogh" and "Vanguard"
        System.out.println(theSet.tailSet("k").toArray().length +
            " elements higher than \"k\"");

        // Print the whole list in sorted order
        System.out.println("Sorted list:");
        theSet.forEach(name -> System.out.println(name));

最后需要注意的一点是,如果你有一个Hashtable或者HashMap,你可以将其转换为TreeMap,从而获得排序,并且只需将其传递给TreeMap构造函数:

TreeMap sorted = new TreeMap(unsortedHashMap);

7.13 在集合中查找对象

问题

你需要查看给定的集合是否包含特定的值。

解决方案

询问集合是否包含给定值的对象。

讨论

如果你已经创建了一个集合的内容,你可能知道其中的内容和不在其中的内容。但是,如果集合是由大型应用程序的另一部分准备的,或者如果你只是将对象放入其中,现在需要查找是否找到了给定的值,则可以使用此方法。根据你使用的集合类别,有各种方法。可以使用表 7-4 中的方法。

表格 7-4. 在集合中查找对象

方法 含义 实现类别
binarySearch() 较快的搜索 ArraysCollections
contains() 查找 ArrayListHashSetHashtableLinkListPropertiesVector
containsKey()containsValue() 检查集合是否包含对象作为KeyValue HashMapHashtablePropertiesTreeMap
indexOf() 返回对象被找到的位置 ArrayListLinkedListListStackVector
search() 查找 Stack

如果集合是一个集合(ListSet),那么以contains开头的方法将使用线性搜索;如果集合是散列的(HashSetHashMap),那么它将非常快速。因此,你必须知道正在使用的实现方式,才能考虑性能问题,特别是当集合很大(或可能会增长)时。

下一个例子是一个小游戏:找到隐藏的数字(或者说是大海捞针):要查找的数字存储在一个数组中。作为游戏来说,它相当无趣:计算机自己对弈,所以你可能知道谁会赢。我之所以这样写,是因为我想知道数据数组包含有效的数字。有趣的部分不是随机数的生成(见配方 5.9)。用于Arrays.binarySearch()的数组必须是排序的,但是因为我们刚刚用随机数填充了它,所以它最初并不是排序的。因此,我们在数组上调用Arrays.sort()。然后我们可以调用Arrays.binarySearch(),传入数组和要查找的值。如果你用一个数字运行程序,它会运行那么多次游戏,并报告整体表现如何。如果你不在乎,它只会玩一次游戏:

public class ArrayHunt  {
    /** the maximum (and actual) number of random ints to allocate */
    protected final static int MAX    = 4000;
    /** the value to look for */
    protected final static int NEEDLE = 1999;
    int[] haystack;
    Random r;

    public static void main(String[] argv) {
        ArrayHunt h = new ArrayHunt();
        if (argv.length == 0)
            h.play();
        else {
            int won = 0;
            int games = Integer.parseInt(argv[0]);
            for (int i=0; i<games; i++)
                if (h.play())
                    ++won;
            System.out.println("Computer won " + won +
                " out of " + games + ".");
        }
    }

    /** Construct the hunting ground */
    public ArrayHunt() {
        haystack = new int[MAX];
        r = new Random();
    }

    /** Play one game. */
    public boolean play() {
        int i;

        // Fill the array with random data (hay?)
        for (i=0; i<MAX; i++) {
            haystack[i] = (int)(r.nextFloat() * MAX);
        }

        // Precondition for binary search is that data be sorted!
        Arrays.sort(haystack);

        // Look for needle in haystack
        i = Arrays.binarySearch(haystack, NEEDLE);

        if (i >= 0) {        // Found it, we win.
            System.out.println("Value " + NEEDLE +
                " occurs at haystack[" + i + "]");
            return true;
        } else {        // Not found, we lose.
            System.out.println("Value " + NEEDLE +
                " does not occur in haystack; nearest value is " +
                haystack[-(i+2)] + " (found at " + -(i+2) + ")");
            return false;
        }
    }
}

Collections.binarySearch()工作方式几乎完全相同,只是它查找Collection中的元素,该Collection必须是排序的(可能使用Collections.sort,如配方 7.11 中所述)。

7.14 将集合转换为数组

问题

你有一个Collection,但是你需要一个 Java 语言数组。

解决方案

使用CollectiontoArray()方法。

讨论

如果你有一个ArrayList或其他Collection,并且你需要一个数组,你可以通过调用CollectiontoArray()方法轻松获取它。如果不提供参数,你会得到一个Object[]类型的数组。你还可以选择提供一个数组参数,用于两个目的:

  • 数组参数的类型决定了返回的数组类型。

  • 如果数组足够大(你可以通过根据Collectionsize()方法分配数组来保证它足够大),那么这个数组就会被填充并返回。如果数组不够大,那么会分配一个新数组。如果你提供了一个数组,并且Collection中的对象不能转换为该类型,那么你将会得到一个ArrayStoreException

示例 7-3 展示了将ArrayList转换为Object类型数组的代码。

示例 7-3. main/src/main/java/structure/ToArray.java
        List<String> list = new ArrayList<>();
        list.add("Blobbo");
        list.add("Cracked");
        list.add("Dumbo");

        // Convert a collection to Object[], which can store objects
        // of any type.
        Object[] ol = list.toArray();
        System.out.println("Array of Object has length " + ol.length);

        String[] sl = (String[]) list.toArray(new String[0]);
        System.out.println("Array of String has length " + sl.length);

7.15 使你的数据可迭代

问题

你已经编写了自己的数据结构,并希望发布数据以便在 for-each 循环中使用。

解决方案

使你的数据类实现 Iterable 接口:这个接口只有一个方法 iterator()。编写你自己的 Iterator。只需实现(或提供一个实现了)Iterator 接口的内部类即可。

讨论

要在现代的 Java for-each 循环中可用,你的数据类必须实现 Iterable 接口,这是一个简单的接口,有一个方法 Iterator<T> iterator()。无论你使用这个接口还是想直接使用老旧的 Iterator 接口,将程序中的数据以一种与存储无关的方式提供给代码的其他部分的方法是生成一个 Iterator。以下是一个简短的程序,根据请求构造一个 Iterator 来访问某些数据,此例中是数组中的数据。Iterator 接口只有三个方法 — hasNext()next()remove() — 在 示例 7-4 中有演示。

示例 7-4. main/src/main/java/structure//IterableDemo
public class IterableDemo {

    /** Demo implements Iterable, meaning it must provide an Iterator,
 * and that it can be used in a foreach loop.
 */
    static class Demo implements Iterable<String> {

        // Simple demo: use array instead of inventing new data structure
        String[] data = { "One", "Two", "Three"};

        /** This is the Iterator that makes it all happen */
        class DemoIterator implements Iterator<String> {
            int i = 0;

            /**
 * Tell if there are any more elements.
 * @return true if next() will succeed, false otherwise
 */
            public boolean hasNext() {
                return i < data.length;
            }

            /** @return the next element from the data */
            public String next() {
                return data[i++];
            }

            /** Remove the object that next() just returned.
 * An Iterator is not required to support this interface, and we don't.
 * @throws UnsupportedOperationException unconditionally
 */
            public void remove() {
                throw new UnsupportedOperationException("remove");
            }
        }

        /** Method by which the Demo class makes its iterator available */
        public Iterator<String> iterator() {
            return new DemoIterator();
        }
    }

    public static void main(String[] args) {
        Demo demo = new Demo();
        for (String s : demo) {
            System.out.println(s);
        }
    }
}

remove() 方法的注释让我想起了一个有趣的点。这个接口引入了 java.util 尝试的一个 Java 实际上并没有的东西,即可选方法。因为没有语法支持,而且他们不想引入任何新的语法,Collections Framework 的开发者们决定使用现有的语法来实现。如果调用未实现的可选方法,它们将要求抛出 UnsupportedOperationException 异常。我的 remove() 方法正是这样做的。请注意,UnsupportedOperationException 是从 RuntimeException 派生的,因此不需要声明或捕获它。

这段代码很简单,但展示了语法并演示了 Iterator 接口的工作原理。在真实的代码中,Iterator 和数据通常是分开的对象(Iterator 可能是数据存储类的内部类)。此外,即使对于数组,你也不必编写这段代码;你可以直接构造一个 ArrayList 对象,将数组元素复制进去,并请求它提供 Iterator。然而,我认为展示这个 Iterator 内部工作原理的简单示例是值得的,这样你可以理解它的工作方式,以及在需要时如何为更复杂的数据结构提供 Iterator

Iterable 接口只有一个非默认方法 iterator(),必须为给定类型的对象提供一个 Iterator。因为 ArrayIterator 类也实现了这一点,我们可以在 “foreach” 循环中使用 ArrayIterator 类型的对象,就像在 示例 7-5 中那样。

示例 7-5. main/src/main/java/structure/ArrayIteratorDemo.java
package structure;

import com.darwinsys.util.ArrayIterator;

public class ArrayIteratorDemo {

    private final static String[] names = {
        "rose", "petunia", "tulip"
    };

    public static void main(String[] args) {
        ArrayIterator<String> arrayIterator = new ArrayIterator<>(names);

        System.out.println("Java 5, 6 way");
        for (String s : arrayIterator) {
            System.out.println(s);
        }

        System.out.println("Java 5, 6 ways");
        arrayIterator.forEach(s->System.out.println(s));
        arrayIterator.forEach(System.out::println);
    }
}

Java 8 中的 Iterable.foreach

Java 8 为Iterator接口添加了foreach,这是一个默认方法(在 Recipe 9.0 中讨论),你不必编写它。因此,在迁移到 Java 8 后,我们可以使用最新风格的循环Iterator.foreach(Consumer),结合 lambda 表达式(参见 Chapter 9)来打印每个元素(参见 Example 7-5)。

7.16 使用对象栈

问题

需要按照后进先出(LIFO)或最近添加的顺序处理数据。

解决方案

为创建一个栈编写你自己的代码很容易。或者,使用java.util.Stack

讨论

需要快速将物品放入一个保持区,并以后进先出的顺序取出它们。这是一个常见的数据结构操作,通常用于颠倒对象的顺序。任何栈的基本操作包括push()(添加到栈)、pop()(从栈中移除)和peek()(查看顶部元素而不移除)。ToyStack在 Example 7-6 中是一个简单的类,用于堆叠原始类型int的值。我将在一页或两页中扩展它,以允许堆叠用户定义的对象。

Example 7-6. main/src/main/java/structure/ToyStack.java
public class ToyStack {

    /** The maximum stack depth */
    protected int MAX_DEPTH = 10;
    /** The current stack depth */
    protected int depth = 0;
    /* The actual stack */
    protected int[] stack = new int[MAX_DEPTH];

    /** push - add an element onto the stack */
    protected void push(int n) {
        stack[depth++] = n;
    }
    /** pop - return and remove the top element */
    protected int pop() {
        return stack[--depth];
    }
    /** peek - return the top element but don't remove it */
    protected int peek() {
        return stack[depth-1];
    }
}

如果你对栈的基本概念不熟悉,你应该先阅读这里的代码;如果你已经熟悉,可以跳过。

在工作中ToyStack2(未显示但在在线源代码中),我将其接口提取为SimpleStack,只列出操作。同时,我添加了empty()方法,以便与标准的java.util.Stack类兼容。更重要的是,我使其成为一个泛型类型,因此可以用于任何类型的值。这在SimpleStack中展示:

public interface SimpleStack<T> {

    /** empty - return true if the stack is empty */
    abstract boolean empty();

    /** push - add an element onto the stack */
    abstract void push(T n);

    /** pop - return and remove the top element */
    abstract T pop();

    /** peek - return the top element but don't remove it */
    abstract T peek();
}

我接着又制作了另一个演示栈类MyStack,来实现这个新接口:

public class MyStack<T> implements SimpleStack<T> {

    private int depth = 0;
    public static final int DEFAULT_INITIAL = 10;
    private T[] stack;

    public MyStack() {
        this(DEFAULT_INITIAL);
    }

    public MyStack(int howBig) {
        if (howBig <= 0) {
            throw new IllegalArgumentException(
            howBig + " must be positive, but was " + howBig);
        }
        stack = (T[])new Object[howBig];
    }

    @Override
    public boolean empty() {
        return depth == 0;
    }

    /** push - add an element onto the stack */
    @Override
    public void push(T obj) {
        // Could check capacity and expand
        stack[depth++] = obj;
    }

    /* pop - return and remove the top element */
    @Override
    public T pop() {
        --depth;
        T tmp = stack[depth];
        stack[depth] = null;
        return tmp;
    }

    /** peek - return the top element but don't remove it */
    @Override
    public T peek() {
        if (depth == 0) {
            return null;
        }
        return stack[depth-1];
    }

    public boolean hasNext() {
        return depth > 0;
    }

    public boolean hasRoom() {
        return depth < stack.length;
    }

    public int getStackDepth() {
        return depth;
    }
}

这个版本增加了许多错误检查(以及一个单元测试,在src/test/java/structure文件夹中),以及一些原始版本中没有的额外方法。一个例子是hasRoom()。不像完整的java.util.StackMyStack不会扩展超出其原始大小,因此我们需要一种方法来查看它是否已满,而不是抛出异常。

现在你已经看到栈是如何工作的,我建议使用提供的java.util.Stack而不是我的演示版本;它更加完整、经过了更多测试,并且被广泛使用。与主要的集合 API 组件ListSetMap不同,java.util.Stack没有接口和实现类;它基于Vector,这是List的一种实现。真正的java.util.Stack与我的类似,但是有更多的方法和更大的灵活性。要查看它的运行方式,Recipe 5.12 提供了一个简单的基于栈的数值计算器。

7.17 多维结构

问题

您需要一个多维数组或ArrayList

解决方案

没问题。Java 支持这一点。

讨论

如 Recipe 7.1 中提到的,Java 数组可以持有任何引用类型。因为数组是一个引用类型,所以您可以有数组的数组,或者换种说法,多维数组。此外,由于每个数组都有自己的长度属性,例如,二维数组的列不必都具有相同的长度(参见 Figure 7-3)。

这里是分配一对二维数组的代码,一个使用循环,另一个使用初始化程序。两者都有选择地打印:

public class ArrayTwoDObjects {

    /** Return list of subscript names (unrealistic; just for demo). */
    public static String[][] getArrayInfo() {
        String info[][];
        info = new String[10][10];
        for (int i=0; i < info.length; i++) {
            for (int j = 0; j < info[i].length; j++) {
                info[i][j] = "String[" + i + "," + j + "]";
            }
        }
        return info;
    }

    /** Run the initialization method and print part of the results */
    public static void main(String[] args) {
        print("from getArrayInfo", getArrayInfo());
    }

    /** Print selected elements from the 2D array */
    public static void print(String tag, String[][] array) {
        System.out.println("Array " + tag + " is " + array.length + " x " +
            array[0].length);
        System.out.println("Array[0][0] = " + array[0][0]);
        System.out.println("Array[0][1] = " + array[0][1]);
        System.out.println("Array[1][0] = " + array[1][0]);
        System.out.println("Array[0][0] = " + array[0][0]);
        System.out.println("Array[1][1] = " + array[1][1]);
    }
}

jcb4 0703

图 7-3. 多维数组

运行它将产生以下输出:

> java structure.ArrayTwoDObjects
Array from getArrayInfo is 10 x 10
Array[0][0] = String[0,0]
Array[0][1] = String[0,1]
Array[1][0] = String[1,0]
Array[0][0] = String[0,0]
Array[1][1] = String[1,1]
Array from getParameterInfo is 2 x 3
Array[0][0] = fontsize
Array[0][1] = 9-18
Array[1][0] = URL
Array[0][0] = fontsize
Array[1][1] = -
>

同样的逻辑可以应用于任何Collections。您可以有ArrayListArrayList,或者Vector的链表,或者您心之所欲的任何内容。

如 Figure 7-3 所示,数组不必是规则的(即二维数组的每列可以具有不同的高度)。这就是为什么在代码示例中我使用了array[0].length来获取第一列的长度的原因。

7.18 使用 Lombok 或记录简化数据对象

问题

您浪费时间编写数据类,这些类是普通的旧 Java 对象(POJO),具有诸如 setter 和 getter、equals()toString()等样板代码。

解决方案

使用 Lombok 自动生成样板方法。在 Java 14+中,请使用新的record数据类型,该类型为您生成样板方法。

讨论

在 Java 刚出现时,在没有好的 IDE 的情况下,开发人员必须手工编写 getter 和 setter,或者复制粘贴修改。当时我研究了一个现有的大型代码库,发现了约 0.5%的失败率。setter 把值存储在错误的位置,或者 getter 检索了错误的值。假设随机分布,这意味着百分之一的 getter 调用会给出错误的答案!应用程序仍然工作,所以我必须假设那些错误的答案并不重要。

现在我们有了可以生成所有样板方法的 IDE,例如 setter/getter、equals、toString()等等。但是您仍然需要记住调用这些生成器。

Lombok

Project Lombok 提供了一个解决方案。它读取您的.class文件,查找其自己的注解,当找到它们时,重写类文件以具有所选的方法。

要使用 Lombok,您需要将依赖项org.projectlombok:lombok:1.18.4(或更新版本)添加到您的构建脚本中。或者,如果您使用的是 IDE,请从https://projectlombok.org下载 Lombok JAR 文件,并按照那里的说明安装它。然后,您可以使用类似以下的注解来注释您的类:

@Setters @Getters

Presto!不再忘记生成这些方法;Lombok 将为您完成这项工作。

其他注解包括以下内容:

@ToString
@EqualsAndHashCode
@AllArgsConstructor

对于数据类,甚至还有@Data,它是@ToString@EqualsAndHashCode、所有字段的@Getter,所有非 final 字段的@Setter以及@RequiredArgsConstructor的快捷方式!

Java 14 record(预览)

新的record类型提供了另一种解决方案。record是用于数据类的类似类的构造,是类似于枚举和注解的受限类形式。您只需编写数据对象的名称及其字段,编译器将提供构造函数、getter、hashCode()equals()toString()

public record Person(String name, String emailAddress) { }

提供的构造函数与记录声明具有相同的签名。所有字段都隐式为 final,并且record提供了 getter 但不提供 setter。Getter 的名称与字段相同;它们不遵循 JavaBeans getName()模式。对于可靠的代码,不可变对象至关重要(参见食谱 9.0)。您可以提供其他成员,如额外的构造函数、静态字段以及静态或实例方法。记录不能是抽象的,也不能声明额外的实例字段。所有这些都与对象的状态如record头部声明的内容一致。在这里,我在 JShell 中创建了一个Person记录,并生成了一个实例:

$ jshell --enable-preview
|  Welcome to JShell -- Version 14-ea
|  For an introduction type: /help intro

jshell> record Person(String name, String email) {}

jshell> var p = new Person("Covington Roderick Smythe", "roddy@smythe.tld")
p ==> Person[name=Covington Roderick Smythe, email=roddy@smythe.tld]

jshell> p.name()
$3 ==> "Covington Roderick Smythe"

jshell>

单行记录定义通常不需要单独的源文件。为了展示一个完整的示例,我将Person记录集成到一个名为PersonRecordDemo的新演示程序中。我们可以将其保存到一个文件中,用javac编译它,然后使用javap查看类的结构:

$ javac --enable-preview -source 14 PersonRecordDemo.java
Note: PersonRecordDemo.java uses preview language features.
Note: Recompile with -Xlint:preview for details.
$ javap PersonRecordDemo'$'Person
Compiled from "PersonRecordDemo.java"
public final class PersonRecordDemo$Person extends java.lang.Record {
  public PersonRecordDemo$Person(java.lang.String, java.lang.String);
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String name();
  public java.lang.String email();
}

文件名中的$必须从 Unix shell 中转义。我们可以看到编译器已生成了构造函数、toString()hashCode()equals(),以及name()email()的只读访问器。

警告

从 Java 14 开始,record机制是一个预览功能,因此可能与此处描述的内容有所不同,甚至(虽然不太可能)在最终的 Java 14 版本或未来的 Java 版本中可能不会出现(尽管我们希望它会如预期那样在 Java 15 中以非预览形式出现)。如果您使用 Java 14,您需要在诸如javapjavacjshell之类的命令上加上--enable-preview选项,以及在读取源文件的命令上加上--source 14选项。

参见

record机制的原始描述和理由在Java Enhancement Proposal JEP-359中可以找到,位于 OpenJDK.net 上。

7.19 程序:时间比较

新开发人员有时会担心这些集合的开销,认为他们应该使用数组而不是数据结构。为了调查,我编写了一个程序,创建并访问了 25 万个对象,一次通过 Java 数组,再通过ArrayList。这比大多数程序使用的对象要多得多。首先是Array版本的代码:

public class Array {
    public static final int MAX = 250000;
    public static void main(String[] args) {
        System.out.println(new Array().run());
    }
    public int run() {
        MutableInteger list[] = new MutableInteger[MAX];
        for (int i=0; i<list.length; i++) {
            list[i] = new MutableInteger(i);
        }
        int sum = 0;
        for (int i=0; i<list.length; i++) {
            sum += list[i].getValue();
        }
        return sum;
    }
}

这里是ArrayList版本的代码:

public class ArrayLst {
    public static final int MAX = 250000;
    public static void main(String[] args) {
        System.out.println(new ArrayLst().run());
    }
    public int run() {
        ArrayList<MutableInteger> list = new ArrayList<>();
        for (int i=0; i<MAX; i++) {
            list.add(new MutableInteger(i));
        }
        int sum = 0;
        for (int i=0; i<MAX; i++) {
            sum += ((MutableInteger)list.get(i)).getValue();
        }
        return sum;
    }
}

基于Vector的版本ArrayVec与其相似到足以不需要大量重复其代码,这些代码可以在线查找到。

怎样计时呢?如同 Recipe 17.7 所述,如果可用,你可以使用操作系统的 time 命令,或者只需使用一点 Java 来计时你的主程序运行。为了可移植性,我选择在一台较老、较慢的机器上使用后者。其确切速度并不重要,因为重要的是仅比较在同一台机器上运行的该程序的不同版本。

最后(鼓声响起),结果如下:

$ java performance.Time Array 
Starting class class Array
1185103928
runTime=4.310
$ java performance.Time ArrayLst
Starting class class ArrayLst
1185103928
runTime=5.626
$ java performance.Time ArrayVec
Starting class class ArrayVec
1185103928
runTime=6.699
$

注意,我忽略了一个经常引用的建议,即给出 ArrayList 大小的良好初始估计。我也以那种方式计时过;在这个例子中,它使总运行时间减少不到 4%。

底线是,ArrayList 的效率与数组相比并非完全糟糕。显然,调用“get”方法会有更多开销,而不是从数组中检索元素。实际执行一些计算的对象方法的开销可能会超过将对象存储在 ArrayList 而不是 Array 中的开销。除非你处理大量对象,否则可能不需要担心这个问题。Vector 稍慢一些,但仍然只有原始数组版本速度的三分之二左右。如果你关心时间,一旦知道 ArrayList 的最终大小,可以将 ArrayList 转换为数组(参见 Recipe 7.14)。

¹ 如果你愿意,可以使用 for 循环自己复制它,但是 System.arrayCopy() 可能会更快,因为它是用本地代码实现的。

² ListSet 都扩展自 Collection

第八章:面向对象技术

8.0 引言

Java 是一种面向对象(OO)语言,传承自 Simula-67、SmallTalk 和 C++的传统。它借用了 C++的语法和 SmallTalk 的思想。Java API 是根据 OO 模型设计和构建的。设计模式(参见同名书籍),如工厂模式和委托模式,广泛应用;理解这些模式将帮助你更好地使用 API 并改进你自己类的设计。

建议或者口头禅

我可以给你很多短小的建议。学习 Java 基础时会出现一些反复出现的主题,我建议在学习更多 Java 时复习一下它们。

使用 API

我无法强调这一点的重要性。很多你需要做的事情已经被开发标准 Java 库(以及第三方库)的好人们做过了。随着每一个新版本的发布,这些事情也在不断增加。充分了解 API 是避免“重复造轮子”这种致命病的良好基础——也就是说,不要浪费时间去发明一个低劣的复制品,而那些一流产品早就为你准备好了。事实上,本书的部分使命就是防止你重新发明已经存在的东西。其中一个例子就是java.util中讨论的 Collections API,见第七章。Collections API 具有很高的通用性和规律性,所以通常没有必要发明你自己的数据结构代码。

规则的例外

有一个例外可以不使用 API 的规则:java.lang.Object中的clone()方法通常应该使用。如果你需要复制一个对象,只需编写一个复制方法或者复制构造函数。Joshua Bloch 在《Effective Java》(Addison-Wesley 出版)中反对clone()方法的论点是有说服力的,任何专注于 Java 编程的人都应该阅读一下。顺便说一句,把整本书都读了吧。

另一个例外是java.lang.Object()中的finalize()方法。不要使用它。自 Java 9 起已经被弃用,因为不能保证它会被调用;但是因为它可能被调用,它会导致你的死对象无法被垃圾收集,从而导致内存泄漏。如果需要某种清理工作,你必须负责定义一个方法并在让该类的任何对象引用失效之前调用它。你可以称这样的方法为cleanUp()。对于应用级别的清理,请参考https://darwinsys.com/java/shutdownhook.html

泛化

在通用性(及由此带来的重用性)与应用特定性便利性之间存在一种权衡。如果您正在按照面向对象设计技术设计非常大的应用程序的一部分,您会考虑一组特定的用例。另一方面,如果您正在编写工具包样式的代码,您应该编写具有少量假设的类。使代码易于从各种程序中使用是编写可重用代码的途径。

阅读和编写 javadoc

您无疑已经在浏览器中查看了 Java 在线文档,部分原因是我告诉您要充分了解 API。您认为 Sun/Oracle 雇佣了数百万技术写作人员来生成所有这些文档吗?不。这些文档存在是因为 API 的开发者花时间编写了 javadoc 注释,您在代码中看到的那些有趣的/**注释。所以,再给您一个建议:使用 javadoc。标准 JDK 提供了一个良好的、标准的 API 文档化机制。并且在编写代码时就要使用它——不要认为您会在以后回来再写。这种明天永远不会来的想法。

详情请参见食谱 15.2 以获取有关使用 javadoc 的详细信息。

使用子类化和委托

使用子类化。但不要过度使用子类化。这不仅是避免代码重复的最佳方式之一,也是开发可工作软件的好方法。有关面向对象设计和编程的更多细节,请参阅任何一本优秀的书籍。

有几种替代方案。子类化的一个替代方案是委托。考虑“是一个”与“有一个”的区别。例如,不要通过子类化NameAndAddress来创建BusinessPartnerCustomer,而是让BusinessPartnerCustomer具有NameAndAddress的实例。这是一个更清晰的结构;仅仅因为合作伙伴有名称和地址而让BusinessPartner 是一个 NameAndAddress是没有意义的。委托还使得Customer能够更轻松地同时具有账单地址和送货地址。另一个替代方案是面向方面的编程(AOP),它允许您从类的外部附加额外的功能。Java EE 使用 EJB 拦截和 Spring Framework AOP 机制提供了 AOP。

使用设计模式

在序言中,我提到设计模式作为面向对象编程中非常重要的书籍之一。通常被称为“四人组”(GoF)书籍,因为它有四位作者,它提供了程序员经常重新发明的事物的强大目录。有些人认为 GoF 书籍的语调有些学术性;一本较为非正式的关于模式的介绍是 Bert Bates 等人的Head First Design Patterns(O’Reilly),它涵盖了与 GoF 书籍相同的两打模式。设计模式提供了问题及其解决方案的陈述,有点像本书,但通常在更高的抽象级别。它对于提供设计的标准词汇以及清楚解释基本模式如何工作及其如何实现同样重要。

表 8-1 展示了标准 API 中设计模式的一些示例用法。

表 8-1. JavaSE API 中的设计模式

模式名称 意义 Java API 中的示例
命令 封装请求,允许请求队列,可撤销操作等 javax.swing.Action; javax.swing.undo.UndoableEdit
装饰器 一个类装饰另一个类 Swing Borders
工厂方法 一个类为您创建实例,由子类控制 getInstance(在CalendarFormatLocale中...); SocketFactory; RMI InitialContext
迭代器 遍历集合中的所有元素,每个元素仅访问一次 Iterator; 旧的Enumeration; java.sql.ResultSet
模型-视图-控制器 模型代表数据; 视图是用户看到的内容; 控制器响应用户请求 ActionListener和其它类; Observer/Observable; 所有可见 Swing 组件内部使用
代理 一个对象代替另一个对象 RMI,AOP,动态代理
单例 只允许一个实例存在 java.lang.Runtime, java.awt.Toolkit

我为Oracle Java Magazine写了关于状态代理命令装饰器访问者模式的文章。

8.1 对象方法:使用 toString()格式化对象,使用 equals()比较

问题

你希望你的对象具有有用的默认格式,并且在放置在Collections类中时行为正常。

解决方案

java.lang.Object继承了四个可重写方法;其中,toString()提供了默认格式化,而equals()hashCode()提供了相等性测试和在Map实现中的高效使用。第四个clone()不建议一般使用。

讨论

toString()

每当你将对象传递给System.out.println()或任何等效方法,或者将其涉及到字符串连接中时,Java 会自动调用它的toString()方法。Java 知道每个对象都有一个toString()方法,因为java.lang.Object有一个,并且所有类最终都是Object的子类。默认实现在java.lang.Object中既不漂亮也不有趣:它只打印类名,一个@符号和对象的hashCode()值。例如,如果你运行以下代码

public class ToStringWithout {
    int x, y;

    /** Simple constructor */
    public ToStringWithout(int anX, int aY) {
        x = anX; y = aY;
    }

    /** Main just creates and prints an object */
    public static void main(String[] args) {
        System.out.println(new ToStringWithout(42, 86));
    }
}

你可能会看到这种无信息的输出:

ToStringWithout@990c747b

要使其打印得更好,你应该提供一个toString()方法的实现,该方法打印类名和所有但最简单类中的一些重要状态。这为你在println()、调试器和任何将你的对象引用在String上下文中的地方提供了格式控制。下面是使用toString()方法重写的前一个程序:

public class ToStringWith {
    int x, y;

    /** Simple constructor */
    public ToStringWith(int anX, int aY) {
        x = anX; y = aY;
    }

    @Override
    public String toString() {
        return "ToStringWith[" + x + "," + y + "]";
    }

    /** Main just creates and prints an object */
    public static void main(String[] args) {
        System.out.println(new ToStringWith(42, 86));
    }
}

此版本生成更有用的输出:

ToStringWith[42,86]

此示例使用了String连接,但你可能还想使用String.format()StringBuilder;请参见第三章。

hashCode()和 equals()

要确保当任何客户端代码调用equals()或者这些对象被存储在Map或其他Collection类中时,你的类能够正常工作,请为你的类提供equals()hashCode()方法。

如何确定相等性?对于算术或布尔操作数,答案很简单:使用等号操作符(==)进行测试。但是对于对象引用,Java 提供了==和继承自java.lang.Objectequals()方法。等号操作符可能会令人困惑,因为它只是比较两个对象引用,看它们是否引用同一个对象。这与比较对象本身的值不同。

继承的equals()方法并不像你想象的那样有用。有些人似乎开始他们作为 Java 开发者的生活时,认为默认的equals()可以神奇地进行详细的、逐字段甚至是二进制的对象比较。但是它不会比较字段!它只是做了可能的最简单的事情:返回两个对象之间==比较的值!所以,对于你编写的任何值类,你可能需要编写一个equals方法。¹ 注意,Map或哈希表(如HashMap)会使用equalshashCode方法。因此,如果你认为可能有人会使用你的类创建实例并将它们放入Map,或者甚至比较你的对象,你应该为此(也为了自己!)实现equals()hashCode()方法,并且要正确实现它们。

大多数 IDE 知道如何生成正确的equals()hashCode()方法,但值得你了解它们的功能,以应对偶尔需要调整生成代码的情况。例如,Eclipse IDE(参见 Recipe 1.3)提供了一个Source菜单项Generate hashCode() and equals();它只会同时生成两者,而不会让你单独生成equals()hashCode()

这是一个正确的equals()方法的规则:

它是自反的。

x.equals(x)必须为真。

它是对称的。

x.equals(y)必须为真当且仅当y.equals(x)也为真。

它是传递的。

如果x.equals(y)为真且y.equals(z)为真,则x.equals(z)必须也为真。

它是幂等的(可重复的)。

x.equals(y)的多次调用返回相同的值(除非比较中使用的状态值已更改,例如通过调用设置方法)。

它是谨慎的。

x.equals(null)必须返回假,而不是意外抛出NullPointerException

此外,要注意一个常见的错误:equals()的参数必须声明为java.lang.Object,而不是它所在的类;这是为了使多态性能够正确工作(某些类可能没有自己的equals()方法)。为了防止这种错误,通常会在equals()覆盖中添加@Override注解,如 Recipe 15.3 中所述。

这里是一个努力实现这些规则的类:

public class EqualsDemo {
    private int int1;
    private SomeClass obj1;

    /** Constructor */
    public EqualsDemo(int i, SomeClass o) {
        int1 = i;
        if (o == null) {
            throw new IllegalArgumentException("Data Object may not be null");
        }
        obj1 = o;
    }

    /** Default Constructor */
    public EqualsDemo() {
        this(0, new SomeClass());
    }

    /** Demonstration "equals" method */
    @Override
    public boolean equals(Object o) {
        if (o == this)                    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
            return true;

        if (o == null)                    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
            return false;

        // Of the correct class?
        if (o.getClass() != EqualsDemo.class) ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
            return false;

        EqualsDemo other = (EqualsDemo)o; // OK, cast to this class 
        // compare field-by-field ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        if (int1 != other.int1)           // compare primitives directly
            return false;
        if (!obj1.equals(other.obj1))     // compare objects using their equals
            return false;
        return true;
    }

    // ...

1

优化:如果是同一个对象,根据定义为真。

2

如果其他对象为空,则根据定义为假。

3

使用!=比较类描述符;见下一段。

4

优化:首先比较原始数据类型。可能值得,也可能不值得;可能更好的是按照最有可能不同的顺序排序——这取决于数据和使用情况。

另一个要避免的常见错误:注意使用类描述符相等性(即o.getClass() != EqualsDemo.class)来确保正确的类,而不是通过instanceof,有时会错误地这样做。equals()方法契约的自反要求几乎使得正确比较子类和超类几乎不可能,因此我们现在使用类相等性(详见 Chapter 17, Reflection, or “A Class Named Class”中关于类描述符的详细信息)。

这里是EqualsDemo类的基本 JUnit 测试(参见 Recipe 1.10):

/** Some JUnit test cases for EqualsDemo.
 * Writing a full set is left as "an exercise for the reader".
 */
public class EqualsDemoTest {

    /** an object being tested */
    EqualsDemo d1;
    /** another object being tested */
    EqualsDemo d2;

    /** Method to be invoked before each test method */
    @Before
    public void setUp() {
        d1 = new EqualsDemo();
        d2 = new EqualsDemo();
    }

    @Test
    public void testSymmetry() {
        assertTrue(d1.equals(d1));
    }

    @Test
    public void testSymmetric() {
        assertTrue(d1.equals(d2) && d2.equals(d1));
    }

    @Test
    public void testCaution() {
        assertFalse(d1.equals(null));
    }
}

通过所有这些测试,可能会出现什么问题?嗯,有些事情仍然需要注意。如果对象是EqualsDemo子类怎么办?我们应该测试在这种情况下返回假。

还有什么问题?嗯,如果 obj1other.obj1 是空的呢?你可能刚刚获得了一个漂亮的新 NullPointerException。因此,你还需要测试任何可能为空的值。良好的构造函数可以避免这些 NullPointerException,就像我在 EqualsDemo 中尝试做的那样,或者显式地测试它们。

最后,你不应该在不同时重写 hashCode() 的情况下重写 equals(),而且相同的字段必须参与两者的计算。

hashCode()

hashCode() 方法应该返回一个 int,它应该唯一地标识其类中对象的任何一组值。

一个正确编写的 hashCode() 方法将遵循这些规则:

它是可重复的。

当调用多次时,hashCode(x) 必须返回相同的 int,除非调用了设置方法。

它与相等性一致

如果 x.equals(y),那么 x.hashCode() 必须等于 y.hashCode()

不同的对象应该产生不同的哈希码

如果 !x.equals(y),则不要求 x.hashCode() != y.hashCode(),但这样做可能会提高哈希表的性能(即在调用 equals() 之前哈希可能会调用 hashCode())。

标准 JDK 上的默认 hashCode() 返回一个机器地址,这符合第一个规则。遵循第二和第三规则部分取决于你的 equals() 方法。以下是一个打印少量对象哈希码的程序:

public class PrintHashCodes {

    /** Some objects to hashCode() on */
    protected static Object[] data = {
        new PrintHashCodes(),
        new java.awt.Color(0x44, 0x88, 0xcc),
        new SomeClass()
    };

    public static void main(String[] args) {
        System.out.println("About to hashCode " + data.length + " objects.");
        for (int i=0; i<data.length; i++) {
            System.out.println(data[i].toString() + " --> " +
                data[i].hashCode());
        }
        System.out.println("All done.");
    }
}

它打印什么?

> javac -d . oo/PrintHashCodes.java
> java oo.PrintHashCodes
About to hashCode 3 objects.
PrintHashCodes@982741a0 --> -1742257760
java.awt.Color[r=68,g=136,b=204] --> -12285748
SomeClass@860b41ad --> -2046082643
All done.
>

Color 对象的哈希码值很有趣。它实际上是这样计算的:

alpha<<24 + r<<16 + g<<8 + b

在这个公式中,rgb 分别是红色、绿色和蓝色分量,而 alpha 是透明度。每个量都存储在一个 32 位整数的 8 位中。如果 alpha 值大于 128,则这个字中的高位—通过将字节移入字的符号位而被设置—导致当作有符号整数打印时整数值显得负数。哈希码值是 int 类型的,所以它们可以是负数。

克隆的困难和替代方案

java.util.Observable 类(设计用于在 AWT 或 Swing 应用程序中实现模型-视图-控制器模式)包含一个私有 Vector,但没有克隆方法来深度克隆它。因此,Observable 对象永远不能安全地被克隆!

关于 clone() 的这个问题以及其他几个问题——比如给定的 clone() 实现是深复制还是浅复制的不确定性——表明 clone() 可能没有想得那么周全。一个替代方案是简单地提供一个复制构造函数或类似的方法:

public class CopyConstructorDemo {
    public static void main(String[] args) {
        CopyConstructorDemo object1 = new CopyConstructorDemo(123, "Hello");
        CopyConstructorDemo object2 = new CopyConstructorDemo(object1);
        if (!object1.equals(object2)) {
            System.out.println("Something is terribly wrong...");
        }
        System.out.println("All done.");
    }

    private int number;
    private String name;

    /** Default constructor */
    public CopyConstructorDemo()  {
    }

    /** Normal constructor */
    public CopyConstructorDemo(int number, String name)  {
        this.number = number;
        this.name = name;
    }

    /** Copy constructor */
    public CopyConstructorDemo(CopyConstructorDemo other)  {
        this.number = other.number;
        this.name = other.name;
    }
    // hashCode() and equals() not shown

8.2 使用内部类

问题

你需要编写一个私有类,或最多在另一个类中使用一个类。

解决方案

使用非公共类或内部类。

讨论

非公共类可以作为另一个类的源文件的一部分编写,但不能在该类内部。内部类是 Java 术语,用于定义在另一个类内部的类。内部类最初在早期的 Java 中因用于 GUI 应用程序的事件处理而流行,但其应用范围更广。

实际上可以在多种上下文中构造内部类。作为类的成员定义的内部类可以在该类的任何位置实例化。在方法内部定义的内部类只能在同一方法中稍后引用。内部类还可以是命名的或匿名的。命名的内部类具有依赖于编译器的完整名称;标准 JVM 使用类似 MainClass$InnerClass 的名称来表示生成的文件。类似地,匿名内部类具有依赖于编译器的名称;JVM 使用 MainClass$1MainClass$2 等。

这些类不能在任何其他上下文中实例化;对 OtherMainClass$InnerClass 等的显式引用在编译时被捕获:

main/src/main/java/oo/AllClasses.java

public class AllClasses {
    public class Data {    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        int x;
        int y;
    }
    public void getResults() {
        JButton b = new JButton("Press me");
        b.addActionListener(new ActionListener() { ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
            public void actionPerformed(ActionEvent evt) {
                Data loc = new Data();
                loc.x = ((Component)evt.getSource()).getX();
                loc.x = ((Component)evt.getSource()).getY();
                System.out.println("Thanks for pressing me");
            }
        });
    }
}

/** Class contained in same file as AllClasses, but can be used
 * (with a warning) in other contexts.
 */
class AnotherClass {                    ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
    // methods and fields here...
    AnotherClass() {
        // Inner class from above cannot be used here, of course
        // Data d = new Data();    // EXPECT COMPILE ERROR
    }
}

1

这是一个内部类,在类 AllClasses 中可以随处使用。

2

这显示了匿名内部类语法,它使用 new 后跟类型,然后是 (){,一个类体,和 }。编译器将分配一个名称;该类将根据需要扩展或实现给定的类型。

3

这是一个非公共类;可以在主类和(带有警告)其他类中使用。

一个问题是内部类保留了对外部类的引用。如果您希望在内部类的生命周期比外部类长时避免内存泄漏,可以将内部类设为 static

实现单方法接口的内部类可以使用 lambda 表达式编写得更加简洁(参见 第九章)。

8.3 通过接口提供回调

问题

您想提供回调,即使无关的类也可以调用回您的代码。

解决方案

使用 Java 接口是一种方式。

讨论

接口是类似于类的实体,只能包含抽象方法和 final 字段。正如我们所见,接口在 Java 中被广泛使用!在标准 API 中,以下是一些常用的接口:

  • Runnable, ComparableCloneable(在 java.lang 中)。

  • ListSetMapEnumeration/Iterator(在 Collections API 中;如您在 第七章 中所见)。

  • ActionListenerWindowListener 和 GUI 层中的其他接口。

  • DriverConnectionStatement 和 JDBC 中的 ResultSet;请参见 https://darwinsys.com/javadatabase

  • 远程接口 —— 客户端和服务器之间的接口被指定为 Interface(在 RMI、CORBA 和 EJB 中)。

假设我们正在生成一个建筑管理系统。为了节能,我们希望能够远程关闭(在夜间和周末)诸如室内灯光和计算机显示器这样大量消耗能源的设备。假设我们有某种远程控制技术。它可以是 BSR 的家庭灯光控制技术 X10 的商业版本,也可以是蓝牙或 802.11——这并不重要。重要的是我们必须非常小心地选择关闭的对象。如果我们自动关闭计算机处理器,这将引起极大的愤怒——人们经常在夜间保持运行状态。如果我们关闭建筑应急照明,这将涉及公共安全问题。²

所以我们提出了 Figure 8-1 中显示的设计。

jcb4 0801

Figure 8-1. 建筑管理系统的类

这些数据类的代码没有显示(它非常琐碎),但它位于在线源码的oo/interfaces目录中。顶层类(例如BuildingLightAsset)是抽象类。你无法实例化它们,因为它们没有任何具体功能。为了在编译时和运行时确保我们永远不能关闭应急照明,我们只需确保表示它的类EmergencyLight不实现PowerSwitchable接口。

注意,在这里我们无法直接使用直接继承。没有一个共同的祖先类同时包括既有ComputerMonitorRoomLights,又没有包括ComputerCPUEmergencyLight。使用接口在不相关的类中定义功能。

我们通过BuildingManagement类演示了它们的使用;这个类不是 Figure 8-1 所示层次结构的一部分,但它使用了来自该层次结构的一组Asset对象。

即使不能被关闭的项目也必须在数据库中,用于各种目的(审计、保险等)。在关闭物品的方法中,代码小心地检查数据库中的每个对象是否是PowerSwitchable接口的实例。如果是,则将对象转换为PowerSwitchable,以便调用其powerDown()方法。如果不是,则跳过该对象,从而防止关闭应急照明或关闭正在运行 SETI@Home、下载大型 MP3 播放列表或执行系统备份的机器的可能性。以下代码展示了这组类的实际应用:

public class BuildingManagement {

    List<Asset> things = new ArrayList<>();

    /** Scenario: goodNight() is called from a timer Thread at 2200, or when
 * we get the "shutdown" command from the security guard.
 */
    public void goodNight() {
        things.forEach(obj -> {
            if (obj instanceof PowerSwitchable)
                ((PowerSwitchable)obj).powerDown();
            });
    }

    // tag::functional[]
    public void goodNightFunctional() {
        things.stream().filter(obj -> obj instanceof PowerSwitchable)
            .forEach(obj -> ((PowerSwitchable)obj).powerDown());
    }
    // end::functional[]

    // goodMorning() would be similar, but call each one's powerUp().

    /** Add a Asset to this building */
    public void add(Asset thing) {
        System.out.println("Adding " + thing);
        things.add(thing);
    }

    /** The main program */
    public static void main(String[] av) {
        BuildingManagement b1 = new BuildingManagement();
        b1.add(new RoomLights(101));    // control lights in room 101
        b1.add(new EmergencyLight(101));    // and emerg. lights.
        // add the computer on desk#4 in room 101
        b1.add(new ComputerCPU(10104));
        // and its monitor
        b1.add(new ComputerMonitor(10104));

        // time passes, and the sun sets...
        b1.goodNight();
    }
}

当您运行此程序时,它显示添加的所有项目,但只关闭了PowerSwitchable类型的项目:

> java oo.interfaces.BuildingManagement
Adding RoomLights@2dc77f32
Adding EmergencyLight@2e3b7f32
Adding ComputerCPU@2e637f32
Adding ComputerMonitor@2f1f7f32
Dousing lights in room 101
Dousing monitor at desk 10104
>

8.4 多态性/抽象方法

问题

您希望每个子类都提供一个或多个方法的自己版本。

解决方案

在父类中将方法声明为抽象;这使得编译器确保每个子类都实现了它。

讨论

一个假设的绘图程序使用Shape的子类来绘制任何东西。Shape有一个名为computeArea()的抽象方法,用于计算给定形状的确切面积:

public abstract class Shape {
    protected int x, y;
    public abstract double computeArea( );
}

例如,Rectangle子类具有一个computeArea()方法,将宽度乘以高度并返回结果:

public class Rectangle extends Shape {
    double width, height;
    public double computeArea( ) {
        return width * height;
    }
}

Circle子类返回πr²:

public class Circle extends Shape {
    double radius;
    public double computeArea( ) {
        return Math.PI * radius * radius;
    }
}

该系统具有很高的通用性。在主程序中,我们可以迭代一个Shape对象的集合,并且这里真正的美妙之处在于,可以在任何Shape子类对象上调用computeArea(),而不必担心它是什么形状。Java 的多态方法会自动调用对象最初构造时所属类中的正确computeArea()方法:

main/src/main/java/oo//shapes/ShapeDriver.java

/** Part of a main program using Shape objects */
public class ShapeDriver {

    Collection<Shape> allShapes;    // created in a Constructor, not shown

    /** Iterate over all the Shapes, getting their areas;
 * this cannot use the Java 8 Collection.forEach because the
 * variable total would have to be final, which would defeat the purpose :-)
 */
    public double totalAreas() {
        double total = 0.0;
        for (Shape s : allShapes) {
            total += s.computeArea();
        }
        return total;
    }

多态对软件维护是一大利好:如果添加了一个新的子类,则主程序中的代码不会改变。此外,所有特定于多边形处理的代码都在一个地方:在Polygon类的源文件中。这比旧语言中使用结构中的类型字段配合分散在软件各处的 case 或 switch 语句要好得多。Java 通过多态使软件更可靠和可维护。

8.5 使用类型安全的枚举

问题

您需要在程序中管理一个离散值的小列表。

解决方案

使用 Java 的enum机制。

讨论

枚举意味着列出所有值。通常您知道变量中只需要一个小列表的可能值,例如一年中的月份、一副牌中的花色或等级,或者主要和次要颜色。C 编程语言提供了一个enum关键字:

enum  { BLACK, RED, ORANGE} color;

在早期,Java 因缺乏枚举而受到批评,许多开发人员希望有枚举。许多人不得不开发自定义类来实现类型安全的枚举模式

但是 C 枚举不是类型安全的;它们只是定义可以在任何整数上下文中使用的常量。例如,即使在带有-Wall(所有警告)的 gcc 3 上,此代码也可以编译而不会警告,而 C++编译器会捕获错误:³

enum { BLACK, RED, ORANGE} color;
enum { READ, UNREAD } state;

/*ARGSUSED*/
int main(int argc, char *argv[]) {
        color = RED;
        color = READ; // In C this will compile, give bad results
        return 0;
}

要在 Java 中复制此错误,只需定义一系列final int值;它仍然不是类型安全的。类型安全意味着您不能意外地使用除给定枚举定义之外的值。关于类型安全枚举模式的权威声明可能是在 Joshua Bloch 的书《Effective Java》(Addison-Wesley)的第 21 项中定义的版本。所有现代 Java 版本都在语言中包含枚举;不再需要使用 Bloch 书中的代码。布洛赫是类型安全枚举规范(enum关键字)的作者之一,因此可以确信 Java 现在很好地实现了他的模式。这些枚举被实现为类,从类java.lang.Enum中(由编译器透明地子类化)继承。与 C 不同,也不同于一系列final int,Java 的类型安全枚举具有以下特点:

  • 它们是可打印的(它们打印为名称,而不是底层的int实现)。

  • 它们几乎和int常量一样快,但是代码更可读。

  • 它们可以轻松地进行迭代。

  • 他们为每个enum类型使用单独的命名空间,这意味着您不必为每个枚举常量添加某种常量名前缀,例如ACCOUNT_SAVINGSACCOUNT_CHECKING等。

枚举常量不会编译到客户端中,因此您可以自由地重新排序enum中的常量,而无需重新编译客户端类。然而,并不意味着您应该这样做;请考虑那些已经持久化了使用它们的对象的情况,以及设计数据库映射的人员使用了枚举的数值。重新排序就是个坏主意!

此外,enum类型也是一个类,因此它可以实现任意接口;您可以为enum类添加构造函数、字段和方法。

与布洛赫在书中的类型安全枚举模式相比:

  • Java 的enum使用起来更简单,更易读(书中的那些需要大量方法,使得编写它们变得麻烦)。

  • 枚举可以在 switch 语句中使用。

因此,它们有许多好处,但几乎没有坑。

enum关键字在声明中与class关键字处于同一级别。也就是说,可以在其自己的文件中使用 public 或 default 访问权限声明enum,也可以像嵌套或内部类一样在类内部声明(参见 Recipe 8.2)。Media.java,在示例 8-1 中展示了一个展示类型安全enum定义的代码示例。

示例 8-1. structure/Media.java
public enum Media {
    BOOK, MUSIC_CD, MUSIC_VINYL, MOVIE_VHS, MOVIE_DVD;
}

请注意,enum就是一个类;查看javapMedia类的看法:

C:> javap Media
Compiled from "Media.java"
public class Media extends java.lang.Enum{
    public static final Media BOOK;
    public static final Media MUSIC_CD;
    public static final Media MUSIC_VINYL;
    public static final Media MOVIE_VHS;
    public static final Media MOVIE_DVD;
    public static final Media[] values( );
    public static Media valueOf(java.lang.String);
    public Media(java.lang.String, int);
    public int compareTo(java.lang.Enum);
    public int compareTo(java.lang.Object);
    static {};
}
C:>

Product.java,在示例 8-2 中展示了一个使用Media枚举的代码示例。

示例 8-2. main/src/main/java/structure/Product.java
public class Product {
    String title;
    String artist;
    Media  media;

    public Product(String artist, String title, Media media) {
        this.title = title;
        this.artist = artist;
        this.media = media;
    }

    @Override
    public String toString() {
        switch (media) {
        case BOOK:
            return title + " is a book";
        case MUSIC_CD:
            return title + " is a CD";
        case MUSIC_VINYL:
            return title + " is a relic of the age of vinyl";
        case MOVIE_VHS:
            return title + " is on old video tape";
        case MOVIE_DVD:
            return title + " is on DVD";
        default:
            return title + ": Unknown media " + media;
        }
    }
}

在示例 8-3,MediaFancy展示了如何向枚举类型添加操作(方法);为该枚举类型的Book值重写了toString()方法。

示例 8-3. main/src/main/java/structure/MediaFancy.java
/** An example of an enum with method overriding */
public enum MediaFancy {
    /** The enum constant for a book, with a method override */
    BOOK {
        public String toString() { return "Book"; }
    },
    /** The enum constant for a Music CD */
    MUSIC_CD,
    /** ... */
    MUSIC_VINYL,
    MOVIE_VHS,
    MOVIE_DVD;

    /** It is generally disparaged to have a main() in an enum;
 * please forgive this tiny demo class for doing so.
 */
    public static void main(String[] args) {
        MediaFancy[] data =  { BOOK, MOVIE_DVD, MUSIC_VINYL };
        for (MediaFancy mf : data) {
            System.out.println(mf);
        }
    }
}

运行MediaFancy程序会产生这样的输出:

Book
MOVIE_DVD
MUSIC_VINYL

换句话说,Book的值以用户友好的方式打印,与其他值的默认打印方式相比。在实际生活中,您可能希望将此扩展到枚举中的所有值。

最后,EnumList在示例 8-4 中展示了如何列出给定枚举可以取的所有可能值;只需迭代枚举类继承的values()方法返回的数组即可。

示例 8-4. structure/EnumList.java
public class EnumList {
    enum State {
        ON, OFF, UNKNOWN
    }
    public static void main(String[] args) {
        for (State i : State.values()) {
            System.out.println(i);
        }
    }
}

EnumList程序的输出当然是这样的:

ON
OFF
UNKNOWN

8.6 使用Optional避免 NPEs

问题

你担心空引用会在代码中引发NullPointerException(NPE)。

解决方案

使用java.util.Optional

讨论

发明了空指针概念的开发者,并且是我们学科的早期重要贡献者,已经将空引用描述为“我的十亿美元错误”。然而,使用null在短期内不会消失。

我们可以做的是明确表示我们在某些情境中担心空指针。为此,Java 8 引入了java.util.Optional类。Optional是围绕可能为 null 的对象引用的对象包装器。Optional包装器有着悠久的历史;在 LLVM 的 ADT 中可以找到类似的构造,在那里它的Optional反过来描述为“在 OCaml 的opt变体精神中”。

Optional可以使用其中一个创建方法来创建:

Optional.empty()

返回一个空的可选值

Optional.of(T obj)

返回包含给定值的非空可选值

Optional.ofNullable(T obj)

返回一个空的可选值或者包含给定值的可选值

这个类的基本操作是根据它是满还是空来表现出不同的行为。Optional对象是不可变的,因此它们不能从一种状态转换到另一种状态。

最简单的用法是调用isEmpty()或其相反的isPresent(),并使用程序逻辑来以不同方式行事。这与使用if语句检查null并没有太大不同,但它将选择放在您面前,使您不太可能忘记检查:

jshell> Optional<String> opt = Optional.of("What a day!");
opt ==> Optional[What a day!]

jshell> if (opt.isPresent()) {
   ...>     System.out.println("Value is " + opt.get());
   ...> } else {
   ...>     System.out.println("Value is not present.");
   ...> }
Value is What a day!

更好的形式将使用orElse方法:

jshell> System.out.println("Value is " + opt.orElse("not present"));
Value is What a day!

一个有用的用例是将值传递到方法中。对象可以在传递给方法之前或之后包装在Optional中;当从一开始就没有使用Optional的代码迁移时,后者非常有用。示例 8-5 中的Item演示可能代表了部分发货跟踪程序、借阅图书馆管理器或任何具有时间相关数据可能缺失的内容。

示例 8-5. main/src/main/java/oo/OptionalDemo.java
        List.of(
            new Item("Item 1", LocalDate.now().plusDays(7)),
            new Item("Item 2")).
                forEach(System.out::println);
    static class Item {
        String name;
        Optional<LocalDate> dueDate;
        Item(String name) {
            this(name, null);
        }
        Item(String name, LocalDate dueDate) {
            this.name = name;
            this.dueDate = Optional.ofNullable(dueDate);
        }

        public String toString() {
            return String.format("%s %s", name,
                dueDate.isPresent() ?
                    "Item is due on " + dueDate.get() :
                    "Sorry, do not know when item is due");
        }
    }

有抛出异常的方法,返回空等方法。还有与 Streams 机制交互的方法(见配方 9.3)。Optional的所有方法列表在javadoc 页面的开头

8.7 强制执行单例模式

问题

你想确保在给定的 Java 虚拟机中只有一个类的实例,或者至少在你的应用程序中只有一个类的实例。

解决方案

在类中实现单例模式有几种方法:

  • 枚举实现

  • 只有私有构造函数(或多个)和一个getInstance()方法

  • 使用 Spring 或 CDI 等框架(配方 8.9)配置为提供普通类的单例式实例化

讨论

通常有用的是确保只创建一个类的实例,通常是通过单一点来处理对某些资源的所有请求。标准 API 中的单例示例是java.lang.Runtime:你无法创建Runtime的实例;你只需调用静态方法Runtime.getRuntime()来获取引用。单例模式也是一个可以轻松实现的设计模式的示例。在所有形式中,单例实现的要点是提供一个实例,其中某些方法可以运行,通常用于控制对某些资源的访问。

最简单的实现方法是使用 Java 的enum来提供单例性。enum机制已经保证了在给定的 JVM 上下文中每个枚举常量只存在一个实例,因此这种技术就利用了这一点,如示例 8-6 所示。

示例 8-6. main/src/main/java/oo/EnumSingleton.java
public enum EnumSingleton {

    INSTANCE;

    // instance methods protected by singleton-ness would be here...

    /** A simple demo method */
    public String demoMethod() {
        return "demo";
    }
}

使用它很简单:

        // Demonstrate the enum method:
        EnumSingleton.INSTANCE.demoMethod();

下一个最简单的实现包括一个私有构造函数和一个保存其结果的字段,以及一个静态访问器方法,名称类似getInstance()

可以通过静态初始化块或更简单的初始化器来为私有字段赋值。然后,getInstance()方法(必须是公共的)简单地返回这个实例:

public class Singleton {

    /**
 * Static Initializer is run before class is available to code, avoiding
 * broken anti-pattern of lazy initialization in instance method.
 * For more complicated construction, could use static block initializer.
 */
    private static Singleton instance = new Singleton();

    /** A private Constructor prevents any other class from instantiating. */
    private Singleton() {
        // nothing to do this time
    }

    /** Static 'instance' method */
    public static Singleton getInstance() {
        return instance;
    }

    // other methods protected by singleton-ness would be here...

    /** A simple demo method */
    public String demoMethod() {
        return "demo";
    }
}

请注意,在getInstance()方法中使用延迟评估(如设计模式中)在 Java 中并不是必要的,因为 Java 已经使用延迟加载。直到调用getInstance()时,你的Singleton类可能都不会被加载,所以尝试通过让getInstance()测试singleton变量是否为空并在那里创建单例来推迟Singleton的构建是没有意义的。

使用这个类同样简单:只需获取实例引用,然后调用其方法:

        // Demonstrate the codeBased method:
        Singleton.getInstance().demoMethod();

一些评论员认为,基于代码的单例应该还提供一个public final clone()方法,只是抛出异常,以避免那些欺骗并clone()单例的子类。然而,很明显,只有私有构造函数的类不能被子类化,因此这种偏执似乎是没有必要的。

参见

java.util中的Collections类具有singletonList()singletonMap()singletonSet()方法,分别返回只包含传递给方法的一个对象的不可变ListMapSet。当然,这并不会将对象转换为防止克隆该对象或构造其他实例的单例模式。

参见原著《设计模式》书第 127 页。

8.8 自定义异常

问题

您希望使用一个特定于应用程序的异常类或两个异常类。

解决方案

继续并对ExceptionRuntimeException进行子类化。

讨论

理论上,您可以直接子类化Throwable,但这被认为是不礼貌的。通常情况下,您会子类化Exception(如果您想要一个受检异常)或RuntimeException(如果您想要一个未受检异常)。受检异常是应用程序开发人员需要通过在调用方法的throws子句中列出它们来捕获或向上抛出的异常。

在子类化任何一个异常时,通常至少应提供以下构造函数:

  • 一个无参数构造函数

  • 一个一个字符串参数的构造函数

  • 一个两参数构造函数——一个字符串消息和一个Throwable原因

如果接收异常的代码对其执行堆栈跟踪操作,则会显示原因,带有“根本原因是”或类似的前缀。示例 8-7 展示了应用程序定义的异常ChessMoveException的这三个构造函数。

示例 8-7. main/src/main/java/oo/ChessMoveException.java
/** A ChessMoveException is thrown  when the user makes an illegal move. */
public class ChessMoveException extends Exception {

    private static final long serialVersionUID = 802911736988179079L;

    public ChessMoveException () {
        super();
    }

    public ChessMoveException (String msg) {
        super(msg);
    }

    public ChessMoveException(String msg, Exception cause) {
        super(msg, cause);
    }
}

参见

Exception的 javadoc 文档列出了大量的子类;您可以首先查看那里是否有您可以使用的子类。

8.9 使用依赖注入

问题

您希望避免类之间的过度耦合,并且希望避免为对象创建/查找而专门编写的过多代码。

解决方案

使用依赖注入框架。

讨论

依赖注入框架允许您将对象传递给您的代码,而不是要求您显式地创建它们(这会将您的代码与实现类名绑定在一起,因为您正在调用构造函数)或查找它们(这可能需要使用可能繁琐的查找 API,如 JNDI,即 Java 命名和目录接口)。

三个最知名的依赖注入框架分别是 Spring FrameworkJava Enterprise Edition’s Context and Dependency Injection (CDI)Google Guice。假设我们有三个类,ModelViewController,实现传统的 MVC 模式。考虑到我们可能希望对其中一些类的不同版本进行管理,尤其是 View,我们将为 Model 的简单版本(在 Example 8-8 中)和 View 的简单版本(在 Example 8-9 中)定义 Java 接口。

示例 8-8. MVC 模型接口
public interface Model {
	String getMessage();
}
示例 8-9. main/src/main/java/di/View.java(MVC 视图接口)
public interface View {

    void displayMessage();

}

这些的实现没有显示,因为它们非常简单,但是可以在线找到。在这个例子中,控制器是一个主程序,不需要接口。首先,让我们看一个 使用依赖注入的主程序版本。显然,View 需要 Model 来获取要显示的数据:

main/src/main/java/di/ControllerTightlyCoupled.java

public class ControllerTightlyCoupled {

    public static void main(String[] args) {
        Model m = new SimpleModel();
        ConsoleViewer v = new ConsoleViewer();
        v.setModel(m);
        v.displayMessage();
    }
}

在这里我们有四个任务要完成:

  1. 创建 Model。

  2. 创建 View。

  3. 将 Model 绑定到 View。

  4. 要求视图显示一些数据。

现在使用依赖注入的版本:

main/src/main/java/di/spring/MainAndController.java - Spring 控制器

public class MainAndController {

    public static void main(String[] args) {
        ApplicationContext ctx =
            new AnnotationConfigApplicationContext("di.spring");
        View v = ctx.getBean("myView", View.class);
        v.displayMessage();
        ((AbstractApplicationContext) ctx).close();
    }
}

在这个版本中,我们只有三个任务:

  1. 设置 Spring 上下文,提供依赖注入框架。

  2. 从上下文获取 View;它已经将 Model 设置到其中!

  3. 要求视图显示一些数据。

此外,我们不依赖于接口的特定实现。

Spring 如何知道要向视图注入或提供一个 Model?它如何知道要为视图使用什么代码?可能会有多个视图接口的实现。当然,我们必须告诉它这些信息,我们将在这里使用注解来做到这一点:

@Named("myView")
public class ConsoleViewer implements View {

    Model messageProvider;

    @Override
    public void displayMessage() {
        System.out.println(messageProvider.getMessage());
    }

    @Resource(name="myModel")
    public void setModel(Model messageProvider) {
        this.messageProvider = messageProvider;
    }

}

虽然 Spring 提供了自己的注解,但也会接受 Java 标准的 @javax.annotation.Resource 注解进行注入和 @java.inject.Named 来指定注入对象。

由于信息在网络上的持久性,如果你搜索 Spring 注入,你可能会找到大量引用旧版 Spring 2.x 配置方法的文章,即使用 XML 配置文件的方法。你仍然可以使用这种方法,但现代 Spring 实践通常是使用 Java 注解来配置依赖关系。

在 Java 企业版(EE)上下文和依赖注入(CDI)中也使用了注解。虽然这在 Web 应用程序中最为广泛使用,我们将重复使用相同的示例,使用 CDI 的开源 Weld 实现。CDI 比 Spring 的 DI 功能更强大;因为在 CDI 中,我们甚至不需要知道从哪个类注入资源,所以甚至不需要 Spring 示例中的接口!首先是控制器或主程序,它需要一些 Weld 特定的导入,因为 CDI 最初是为企业应用程序设计的:

public class MainAndController {
    public static void main(String[] args) {
        final Instance<Object> weldInstance = new Weld().initialize().instance();
        weldInstance.select(ConsoleViewer.class).get().displayMessage();
    }
}

View接口被两个实现共享。ConsoleViewer的实现也类似,只是它不与模型耦合;它只是请求注入一个String。在这个简单的示例中,应用程序中只有一个String;在更大的应用程序中,您需要一个额外的注解来指定要注入的字符串。这里是 CDI 的ConsoleViewer

public class ConsoleViewer implements View {
    @Inject @MyModel
    private String message;

    @Override
    public void displayMessage() {
        System.out.println(message);
    }
}

注入的String来自哪里?仍然来自模型:

main/src/main/java/di/cdi/ModelImpl.java

public class ModelImpl {

    public @Produces @MyModel String getMessage(InjectionPoint ip)
        throws IOException {

        ResourceBundle props = ResourceBundle.getBundle("messages");
        return props.getString(
            ip.getMember().getDeclaringClass().getSimpleName() + "." +
            ip.getMember().getName());
    }
}

另请参阅

Spring DI、Java EE CDI 和 Guice 都提供强大的依赖注入。Spring 的使用更广泛;Java EE 具有相同的功能,并且内置于每个 EE 容器中。这三种方法可以独立使用,也可以在 Web 应用程序中使用,只需进行轻微调整即可。在 EE 中,Spring 为 Web 应用程序提供了特别支持,在 EE 容器中,CDI 已经设置好,因此在 EE 应用程序中不需要CDIMain示例中的第一条语句。关于 Spring 有很多书籍。有一本书专门介绍了 Weld:JBoss Weld CDI for Java Platform,作者是 Ken Finnegan(O'Reilly)。

8.10 程序:绘图仪

不是因为它非常复杂,而是因为它很简单,这个程序作为我们在本章涵盖的一些内容的示例,同时,在它的子类中,它还提供了其他讨论的起点。这个类描述了一系列老式(即 20 世纪 70 年代和 80 年代常见的)钢笔绘图仪。钢笔绘图仪是一种将钢笔移动到纸张上并画图的设备。它可以抬起笔或放下笔,并且可以画线条、字母等。在激光打印机和喷墨打印机兴起之前,钢笔绘图仪是准备各种图表以及演示幻灯片的主要手段(这是在像 Harvard Presents 和 Microsoft PowerPoint 之类的程序兴起之前)。今天,几乎没有公司还在生产钢笔绘图仪,但我在这里使用它们是因为它们足够简单,可以从这个简短的描述中理解。今天的 3D 打印机可以被认为是钢笔绘图仪的一种复苏,只是增加了一个额外的运动轴。还有一个更高级的笔。

我将介绍一个高级类,抽象出由不同供应商制造的一系列这样的绘图机的关键特征。例如,它将用于分析或数据探索程序中,绘制显示数据关系的彩色图表。但我不希望我的主程序担心任何特定品牌的细节,所以我将其抽象为一个 Plotter 类,其源代码如下:

main/src/main/java/plotter/Plotter.java

/**
 * Plotter abstract class. Must be subclassed
 * for X, DOS, Penman, HP plotter, etc.
 *
 * Coordinate space: X = 0 at left, increases to right.
 *        Y = 0 at top, increases downward (same as AWT).
 *
 * @author    Ian F. Darwin
 */
public abstract class Plotter {
    public final int MAXX = 800;
    public final int MAXY = 600;
    /** Current X co-ordinate (same reference frame as AWT!) */
    protected int curx;
    /** Current Y co-ordinate (same reference frame as AWT!) */
    protected int cury;
    /** The current state: up or down */
    protected boolean penUp;
    /** The current color */
    protected int penColor;

    Plotter() {
        penUp = true;
        curx = 0; cury = 0;
    }
    abstract void rmoveTo(int incrx, int incry);
    abstract void moveTo(int absx, int absy);
    abstract void penUp();
    abstract void penDown();
    abstract void penColor(int c);

    abstract void setFont(String fName, int fSize);
    abstract void drawString(String s);

    /* Concrete methods */

    /** Draw a box of width w and height h */
    public void drawBox(int w, int h) {
        penDown();
        rmoveTo(w, 0);
        rmoveTo(0, h);
        rmoveTo(-w, 0);
        rmoveTo(0, -h);
        penUp();
    }

    /** Draw a box given an AWT Dimension for its size */
    public void drawBox(java.awt.Dimension d) {
        drawBox(d.width, d.height);
    }

    /** Draw a box given an AWT Rectangle for its location and size */
    public void drawBox(java.awt.Rectangle r) {
        moveTo(r.x, r.y);
        drawBox(r.width, r.height);
    }

    /** Show the current location; useful for
 * testing, if nothing else.
 */
    public Point getLocation() {
        return new Point(curx, cury);
    }
}

注意抽象方法的多样性。与运动、笔控制或绘图相关的方法因实现运动方式在不同设备上的不同而被留在抽象状态。但是,绘制矩形的方法(drawBox)有一个默认实现,它简单地将当前选定的笔移动到上次移动到的位置,绘制四条边界,并抬起笔。更智能的绘图机的子类可能会重写这个方法,但是较低级的绘图机的子类可能会使用默认版本。这个方法还有两个重载的方便方法,用于客户端有 AWT 尺寸维度或 AWT 矩形位置和尺寸的情况。

要演示该程序的一个子类,请考虑以下简单的驱动程序。这旨在模拟更大的图形应用程序,如 gnuplot。main 开头附近的 Class.forName() 在 Recipe 17.2 中有讨论;暂时,你可以相信它只是创建给定子类的一个实例,我们将其存储在名为 rPlotter 引用中,并用它来绘制图表:

main/src/main/java/plotter/PlotDriver.java

public class PlotDriver {

    /** Construct a Plotter driver, and try it out. */
    public static void main(String[] argv) {
        Plotter r ;
        if (argv.length != 1) {
            System.err.println("Usage: PlotDriver driverclass");
            return;
        }
        try {
            Class<?> c = Class.forName(argv[0]);
            Object o = c.newInstance();
            if (!(o instanceof Plotter))
                throw new ClassNotFoundException("Not instanceof Plotter");
            r = (Plotter)o;
        } catch (ClassNotFoundException e) {
            System.err.println("Sorry, class " + argv[0] +
                    " not a plotter class");
            return;
        } catch (Exception e) {
            e.printStackTrace();
            return;
        }
        r.penDown();
        r.penColor(1);
        r.moveTo(200, 200);
        r.penColor(2);
        r.drawBox(123, 200);
        r.rmoveTo(10, 20);
        r.penColor(3);
        r.drawBox(123, 200);
        r.penUp();
        r.moveTo(300, 100);
        r.penDown();
        r.setFont("Helvetica", 14);
        r.drawString("Hello World");
        r.penColor(4);
        r.drawBox(10, 10);
    }
}

我们在接下来的章节中并不展示该 Plotter 类的任何实际子类,但在同一源文件夹中有一个 PlotterAWT 的概念验证,可以为 PostScript、PDF 或其他输出技术实现它。

¹ 值类主要用于保存状态而不是逻辑:Person 是一个值类,而 java.lang.Math 不是。许多类介于两者之间。

² 当然,这些灯可能没有远程断电功能。但计算机可能会有,用于维护目的。

³ 对于那些不太熟悉 C/C++ 的 Java 开发者来说,C 是旧的非面向对象语言;C++ 是 C 的面向对象衍生语言;Java 在某种程度上是 C++ 的便携式、更加类型安全的衍生语言。

第九章:函数式编程技术:函数接口、流和并行集合

9.0 引言

Java 是一种面向对象(OO)语言。你知道这是什么。函数式编程(FP)近来引起了关注。关于 FP 的定义或许不如 FP 语言多,但也差不多。维基百科对函数式编程的定义如下(来源于https://en.wikipedia.org/wiki/Functional_programming,2013 年 12 月访问):

一种编程范式,一种构建计算机程序结构和元素的风格,将计算视为数学函数的评估,并避免状态和可变数据。函数式编程强调的是产生结果仅依赖于其输入而不依赖于程序状态的函数,即纯数学函数。这是一种声明式编程范式,意味着编程是通过表达式完成的。在函数式代码中,函数的输出值仅取决于作为函数输入的参数,因此对于相同参数 x 两次调用函数 f 将产生相同的结果 f(x)。消除副作用,即不依赖于函数输入的状态变化,可以大大增强理解和预测程序行为的能力,这是开发函数式编程的关键动机之一。

我们如何从 FP 范式中受益?一种方式是转向使用 FP 语言;一些主要的 FP 语言包括 Haskell¹、Idris、Ocaml、Erlang、Julia 和 LISP 家族。但大多数这些语言都要求放弃 Java 生态系统。你可以考虑使用ScalaClojure,这些是基于 JVM 的语言,在面向对象语言的背景下提供函数式编程支持。还有Kotlin,这是最新的类 Java 语言。

但这是《Java Cookbook》,因此可以想象,我们将尝试在保持 Java 语言的同时获得尽可能多的函数式编程好处。FP 的一些特征包括以下内容:

  • 纯函数没有副作用,其结果仅取决于它们的输入,而不依赖于程序中其他地方的可变状态

  • 一级函数(例如,函数作为数据)

  • 不可变数据

  • 广泛使用递归和惰性评估

纯函数 是完全自包含的;它们的操作仅取决于输入参数和内部逻辑,而不取决于程序其他部分的任何变量状态——事实上,没有全局变量,只有全局 常量。虽然这对于那些受过 Java 等命令式语言教育的人来说可能难以接受,但这确实使测试和确保程序正确性变得更加容易!这意味着,无论程序中发生了什么(即使是多个线程),像 computeValue(27) 这样的方法调用每次都会无条件地返回相同的值(当然,有一些例外情况,比如当前时间、随机种子等,这些是全局状态)。

我们在本章中将 函数方法 这两个术语互换使用,虽然这不是严格正确的。函数式编程人员在数学函数的意义上使用术语 函数,而在 Java 中 方法 仅表示您可以调用的一些代码(Java 方法调用在面向对象的视角中也被称为向对象 发送消息)。

函数作为数据 意味着你可以创建 一个函数对象,将其传递到另一个函数中,编写一个返回另一个函数的函数,等等——不需要特殊的语法,因为,嗯,函数 就是 数据。

Java 中的函数式编程方法之一是定义功能性接口。在 Java 中,功能性接口 是只有一个抽象方法的接口,例如广泛使用的 Runnable,其唯一方法是 run(),或常见的 Swing 动作处理程序 ActionListener,其唯一方法是 actionPerformed(ActionEvent)。实际上,也是 Java 8 中的新功能,接口可以具有使用 default 关键字注释的方法。接口中的 default 方法可用于实现接口的任何类中。这种方法不能依赖于特定类中的实例状态,因为它们在编译时无法引用它。

所以一个功能性接口更精确的定义是具有单一非默认方法的接口。如果你使用功能性接口,并且限制方法中的代码不依赖于任何非最终实例或类字段,则可以在 Java 中进行函数式编程;使用默认方法是实现这一目标的一种方法。本章的前几个示例讨论了功能性接口。

另一个 Java 实现功能性的方法是 lambda 表达式。Lambda 是功能性接口的表达式,它可以被用作数据(即,赋值、返回等)。现在只给出几个简短的例子:

ActionListener x = e -> System.out.println("You activated " + e.getSource());
public class RunnableLambda {

    public static void main(String[] args) {
        threadPool.submit(() -> System.out.println("Hello from a thread"));

在理论上,不可变数据很容易:只需有一个只有读取访问器(“get”方法)的类。例如,标准的 String 类是不可变的:诸如 substring()toUpperCase() 的方法并不改变原始字符串,而是根据请求生成新的字符串对象。然而字符串是普遍使用的,也很有用。枚举类型也是隐式不可变的。在 Java 14 或 15 中有提议添加一种称为 record 的新型类似对象。record 是隐式不可变的;编译器为字段生成“get”方法(以及一个构造函数和三个通用的 Object 方法),但不生成“set”方法。

Java 8 中还有 Stream 类的概念。Stream 就像一个管道,你可以输入、扩展、收集——类似于 Unix 管道的概念和 Google 的分布式编程概念 MapReduce 的交叉,如 Hadoop 所示,但在单个 VM、单个程序中运行。Stream 可以是顺序的或并行的;后者设计用于利用硬件设计中发生的大规模并行性(特别是服务器,其中 12 核和 16 核处理器很流行)。我们在本章的多个示例中讨论 Stream

如果你熟悉 Unix 的管道和过滤器,这种等价性对你来说就很容易理解;如果不熟悉,现在可以跳过它。Unix 命令是这样的:

cat lines.txt | sort | uniq | wc -l

Java Stream 的等价物是这样的:

jshell> long numberLines =
    new BufferedReader(
    new FileReader("lines.txt")).lines().sorted().distinct().count();
numberLines ==> 5

这些命令在 Example 9-1 中更符合 Java 的惯用语法。两种方法得到相同的答案。对于小输入,Unix 管道更快;但对于较大的数据量,尤其是并行化时,Java 的方法应该更快。

Example 9-1. main/src/main/java/functional/UnixPipesFiltersReplacement.java
        long numberLines = Files.lines(Path.of(("lines.txt")))
            .sorted()
            .distinct()
            .count();
        System.out.printf("lines.txt contains " + numberLines + " unique lines.");

Stream 相关的是 Spliterator 的概念,它是熟悉的 Iterator 的派生物(逻辑上而非继承上),但设计用于并行处理。大多数用户不需要开发自己的 Spliterator,并且通常不会直接调用其方法,因此我们不会详细讨论它们。

参见

要了解有关函数式编程的一般信息,请参阅 Neal Ford 的书 Functional Thinking(O’Reilly)。

还有一整本书专门讨论 lambda 表达式和相关工具,Richard Warburton 的 Java 8 Lambdas(O’Reilly)。

9.1 使用 Lambdas/Closures 替代内部类

问题

想要避免即使是匿名内部类风格所需的所有输入。

解决方案

使用 Java 的 lambda 表达式。

讨论

符号 lambda (λ) 是希腊字母表中的第 11 个字母,因此与西方社会一样古老。Lambda 演算与我们的计算概念一样古老。在这个上下文中,Lambda 表达式是可以引用的小计算单位。它们是数据的函数。从这个意义上说,它们很像匿名内部类,但最好将它们视为匿名方法。它们主要用于替换内部类,用于函数接口——即一个只有一个抽象方法(函数)的接口。一个非常常见的例子是 AWT 的 ActionListener 接口,在 GUI 代码中广泛使用,其唯一方法如下:

public void actionPerformed(ActionEvent);

使用 Lambda 现在是编写 GUI 动作监听器的首选方法。这里有一个单一的示例:

quitButton.addActionListener(e -> shutDownApplication(0));

因为现在不是每个人都写 Swing GUI 应用程序,所以让我们从一个不需要 GUI 编程的示例开始。假设我们有一组已经从数据库加载到内存中的相机型号描述符对象,并且我们希望为它们编写一个通用的 API 来进行搜索,以供应用程序的其他部分使用。

第一个想法可能沿着以下思路:

public interface CameraInfo {
    public List<Camera> findByMake();
    public List<Camera> findByModel();
    ...
}

也许您已经能够看出问题所在了。随着应用程序复杂度的增加,您还需要编写 findByPrice()findByMakeAndModel()findByYearIntroduced() 等等。

您可以考虑实现一个例如查询方法,其中您传入一个Camera对象,其所有非空字段都用于比较。但是,如何实现查找价格低于$500的可换镜头相机?²

因此,更好的方法可能是使用回调函数来进行比较。然后,您可以提供一个匿名内部类来执行任何需要的搜索。您可能希望能够编写像这样的回调方法:

public boolean choose(Camera c) {
    return c.isIlc() && c.getPrice() < 500;
}

因此,我们将把它构建成一个接口:³

/** An Acceptor accepts some elements from a Collection */
public interface CameraAcceptor {
    boolean choose(Camera c);
}

现在搜索应用程序提供了一个方法:

public List<Camera> search(CameraAcceptor acc);

我们可以像这样调用这段代码:

results = searchApp.search(new CameraAcceptor() {
    public boolean choose(Camera c) {
        return c.isIlc() && c.getPrice() < 500;
    }
}

或者,如果您不喜欢匿名内部类,您可能需要键入以下内容:

class MyIlcPriceAcceptor implements CameraAcceptor {
    public boolean choose(Camera c) {
        return c.isIlc() && c.getPrice() < 500;
    }
}
CameraAcceptor myIlcPriceAcceptor = nwq MyIlcPriceAcceptor();
results = searchApp.search(myIlcPriceAcceptor);

这实际上是大量的打字,只是为了将一个方法打包发送到搜索引擎中。Java 对 Lambda 表达式或闭包的支持在专家们达成一致之前(字面上)已经争论了很多年。结果是令人惊讶地简单。Java Lambda 表达式的一种思考方式是,每一个都只是实现功能接口的方法。使用 Lambda 表达式,您可以将前述代码简化为:

results = searchApp.search(c -> c.isIlc() && c.getPrice() < 500);

箭头符号 -> 表示要执行的代码。如果它是一个简单的表达式,如此处所示,您可以直接编写它。如果有条件逻辑或其他语句,则必须使用块,如在 Java 中通常做的那样。

在这里,我只是重新编写搜索示例以展示它作为一个代码块:

results = searchApp.search(c -> {
    if (c.isIlc() && c.getPrice() < 500)
        return true;
    else
        return false;
});

括号内的第一个 c 对应于显式实现的 choose() 方法中的 Camera c:您可以省略类型,因为编译器已经知道它!如果方法有多个参数,您必须将它们括在括号中。假设我们有一个比较方法,它接受两个相机并返回一个定量值(哦,祝你试图让两个摄影师对那个算法达成一致好运!):

double goodness = searchApp.compare((c1, c2) -> {
    // write some amazing code here
});

这种 lambda 的概念似乎非常强大,确实如此!随着 Java 8 正成为计算主流,您将在 Java 中看到更多这样的内容。

直到这里,我们仍然需要为每种类型的方法编写一个接口,以便将其转换为 lambda 表达式。下一个示例展示了一些预定义接口,您可以使用它们进一步简化(或至少缩短)您的代码。

当然,还有许多现有的函数接口,例如来自 GUI 应用程序的 ActionListener 接口。有趣的是,IntelliJ IDE(参见 Recipe 1.3)会自动识别可由 lambda 替换的内部类定义,并在使用 代码折叠(IDE 功能,将整个方法定义表示为单行)时,将内部类替换为相应的 lambda!图 9-1 和 9-2 展示了此代码折叠前后的情况。

jcb4 0901

图 9-1. IntelliJ 展开的代码

jcb4 0902

图 9-2. IntelliJ 代码折叠

9.2 使用 Lambda 预定义接口而不是自己定义

问题

您希望使用现有的接口,而不是定义自己的接口,以便与 Lambda 一起使用。

解决方案

使用 Java 8 中来自java.util.function的 lambda 函数接口。

讨论

在 Recipe 9.1 中,我们使用了接口方法 acceptCamera(),该方法在 CameraAcceptor 接口中定义。接收器类型方法非常常见,因此包 java.util.function 包含了 Predicate<T> 接口,我们可以使用它来替代 CameraAcceptor。该接口只有一个方法 — boolean test(T t)

interface Predicate<T> {
    boolean test(T t);
}

此包包含大约 50 种最常用的功能接口,例如 IntUnaryOperator,它接受一个 int 参数并返回一个 int 值;LongPredicate,它接受一个 long 并返回 boolean 等等。

要使用 Predicate 接口,与任何泛型类型一样,我们为参数 Camera 提供了一个实际类型,得到了(在本例中)参数化类型 Predicate<Camera>,它如下所示(尽管我们不必将其写出来):

interface Predicate<Camera> {
    boolean test(Camera c);
}

因此,我们的搜索应用程序现在将更改为提供以下搜索方法:

public List<Camera> search(Predicate p);

方便的是,这与我们自己的 CameraAcceptor 从 lambda 方法实现的角度来看具有相同的签名,因此我们的其余代码不需要更改!这仍然是对 search() 方法的有效调用:

results = searchApp.search(c -> c.isIlc() && c.getPrice() < 500);

这是 search 方法的实现:

main/src/main/java/functional/CameraSearchPredicate.java

    public List<Camera> search(Predicate<Camera> tester) {
        List<Camera> results = new ArrayList<>();
        privateListOfCameras.forEach(c -> {
            if (tester.test(c))
                results.add(c);
        });
        return results;
    }

假设我们只需要对每个元素执行一个操作,然后将其丢弃。经过反思,我们实际上不需要获取列表返回;我们只需依次获取匹配我们的 Predicate 的每个元素的钩子。

自定义功能接口

虽然 JDK 提供了一组良好的功能性接口,但可能会有需要创建自己的情况。这是一个功能性接口的简单示例:

interface MyFunctionalInterface {
    int compute(int x);
}

@FunctionalInterface 注解告诉编译器确保给定的接口是功能性的并且保持这种状态。它的使用类似于 @Override (这两个注解都在 java.lang 中)。这是可选的。

MyFunctionalInterface 可用于处理整数数组,如下所示:

public class ProcessIntsUsingFunctional {
    static int[] integers = {1, 2, 3};

    public static void main(String[] args) {
        int total = 0;
        for (int i : integers)
            total += process(i, x ->  x * x + 1);
        System.out.println("The total is " + total);
    }

    private static int process(int i, MyFunctionalInterface o) {
        return o.compute(i);
    }
}

如果 compute 是一个非功能接口——有多个抽象方法——那么你将无法以这种方式使用它。

当然,有时候你确实需要一个接口有多于一个方法。在这种情况下,可以通过使用 default 关键字标记除一个方法外的所有方法来保留功能性的幻觉(或效果)—非默认方法仍可在 lambda 中使用。默认方法具有方法体:

public interface ThisIsStillFunctional {
    default int compute(int ix) { return ix * ix + 1 };
    int anotherMethod(int y);
}

只有默认方法可以包含可执行语句,并且每个功能接口只能有一个非默认方法。

顺便说一下,前面提到的 MyFunctionalInterface 完全可以被 java.util.function.IntUnaryOperator 替代,将方法名 apply() 改为 applyAsInt()。在 javasrc 仓库中有一个名为 ProcessIntsIntUnaryOperatorProcessInts 程序版本。

接口中的默认方法可以用于生成 mixin,如 Recipe 9.7 所述。

9.3 简化流处理

问题

您希望通过类似管道的机制处理一些数据。

解决方案

使用 Stream 类及其操作。

讨论

Streams 是在 Java 8 中引入的一种新机制,允许集合通过类似管道的机制逐个发送其值,可以以不同方式进行处理,具有不同的并行程度。与 Stream 相关的方法有三种类型:

  • 流生成方法(参见 Recipe 7.3)。

  • 流传递方法,对流进行操作并返回对其的引用,以允许 流畅编程(链式方法调用);例如 distinct()filter()limit()map()peek()sorted()unsorted()

  • 流终止方法,结束流操作;例如 collect()count()findFirst()max()min()reduce()sum()

在 示例 9-2 中,我们有一个表示超级英雄的 Hero 对象列表。我们使用 Stream 机制来过滤出成年英雄,并计算他们的年龄总和。然后我们再次使用它来按字母顺序对英雄的名称进行排序。

在这两个操作中,我们从流生成器 (Arrays.stream()) 开始;我们通过几个步骤运行它,其中一个步骤涉及映射操作(不要与 java.util.Map 混淆!),导致不同的值在管道中传递。流通过终端操作完成。映射和过滤操作几乎总是由 lambda 表达式控制(在这种编程风格中使用内部类会太麻烦!)。

示例 9-2. main/src/main/java/functional/SimpleStreamDemo.java
    static Hero[] heroes = {
        new Hero("Grelber", 21),
        new Hero("Roderick", 12),
        new Hero("Francisco", 35),
        new Hero("Superman", 65),
        new Hero("Jumbletron", 22),
        new Hero("Mavericks", 1),
        new Hero("Palladin", 50),
        new Hero("Athena", 50) };

    public static void main(String[] args) {

        long adultYearsExperience = Arrays.stream(heroes)
                .filter(b -> b.age >= 18)
                .mapToInt(b -> b.age).sum();
        System.out.println("We're in good hands! The adult superheros have " +
                adultYearsExperience + " years of experience");

        List<Object> sorted = Arrays.stream(heroes)
                .sorted((h1, h2) -> h1.name.compareTo(h2.name))
                .map(h -> h.name)
                .collect(Collectors.toList());
        System.out.println("Heroes by name: " + sorted);
    }

让我们运行一下,确保它能正常工作:

We're in good hands! The adult superheroes have 243 years of experience
Heroes by name: [Athena, Francisco, Grelber, Jumbletron, Mavericks, Palladin,
                 Roderick, Superman]

查看 java.util.stream.Stream 接口的 javadoc,了解所有操作的完整列表。

9.4 使用收集器(Collectors)简化流。

问题

如果你构建的流(Streams)过于复杂或效率低下。

解决方案

使用 Collector

讨论

示例 9-2 在第一半部分以调用 collect() 结束。collect() 的参数是 Collector 类型,本篇介绍将更详细地考虑这种类型。Collector 是经典的函数式编程语言称为 folds 的一种形式。在函数式编程中,fold 也称为 reduce、accumulate、aggregate、compress 或 inject 操作。Fold 是一种终端操作,类似于将整个票据串折叠成一堆平整的操作(见 图 9-3)。票据串代表 Stream,折叠操作由一个函数表示,最终结果是折叠的全部结果。通常包括一个结合操作,类似于在折叠过程中对票据进行计数。

折叠

图 9-3. 折叠前、折叠中和折叠后的票据流:终端操作

注意,在 图 9-3 的第一个面板中,我们不知道 Stream 的长度,但我们预期它最终会终止。

在 Java 中,Collector 是指一个终端函数,用于分析/汇总 Stream 的内容。技术上讲,Collector 是一个接口,其实现由三(或四)个函数组成,这些函数共同工作将条目累积到集合、映射或其他可变结果容器中,并在最终结果上可选执行最终的转换。这些函数如下:

  • 创建一个新的结果容器(supplier())。

  • 将新的数据元素添加到结果容器(accumulator())中。

  • 将两个结果 容器 合并为一个(combiner())。

  • 在结果容器上执行最终转换(可选的 finisher())。

虽然你可以轻松地组合自己的Collector实现,但通常使用Collectors类中预定义的许多有用的Collector更为方便。以下是一些简单示例:

int howMany = cameraList.stream().collect(Collectors.counting());
double howMuch = cameraList.filter(desiredFilter).
	collect(Collectors.summingDouble(Camera::getPrice);

在示例 9-3 中,我实现了经典的单词频率统计算法:获取文本文件,将其拆分为单个单词,计算每个单词的出现次数,并列出使用频率最高的n个单词,按出现频率降序排序。

在 Unix 术语中,这可以实现为(假设n = 20):

prep $file | sort | uniq -c | sort -nr | head -20

其中prep是一个使用 Unix 工具tr将行分成单词并将单词转换为小写的脚本。

示例 9-3. main/src/main/java/functional/WordFreq.java
package functional;

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;

/**
 * Implement word frequency count, in two statements
 */
public class WordFreq {
    public static void main(String[] args) throws IOException {

        // 1) Collect words with a mutable reduction into Map<String,Long>.
        Map<String,Long> map = Files.lines(Path.of(args[0]))
            .flatMap(s -> Stream.of(s.split(" +")))
            .collect(Collectors.groupingBy(
                String::toLowerCase, Collectors.counting()));

        // 2) Print results sorted numerically descending, limit 20
        map.entrySet().stream()
            .sorted(Map.Entry.<String,Long>comparingByValue() .reversed())
            .limit(20)
            .map(entry -> String.format("%4d %s", entry.getValue(), entry.getKey()))
            .forEach(System.out::println);
    }
}

有两个步骤。首先,创建单词及其频率的映射。其次,以相反的顺序排序这些,在第 20 个位置停止,并将它们整齐地格式化并打印出来。

第一部分使用来自第十章的Files.lines()来获取StringStream,使用Stream方法flatMap()String方法split()结合,根据一个或多个空格进行拆分为单个单词。然后使用Collector将结果收集到一个映射中。我最初使用的是自制的收集器:

.collect(HashMap::new, (m,s)->m.put(s, m.getOrDefault(s,0)+1), HashMap::putAll);

这种形式的collect()接受三个参数:

  • 一个Supplier<R>或工厂方法来创建一个空容器;在这里,我只是使用HashMap构造函数。

  • 一个类型为BiConsumer<R,? super T>的累加器,将每个元素添加到映射中,在发现相同的单词时每次添加一个。

  • 类型为BiConsumer<R,R> combiner)的组合器,用于组合所有使用的集合。

在并行流的情况下(参见示例 9.5),Supplier可能会被多次调用以创建多个容器,并且流的每个部分将由一个Accumulator处理到一个容器中。Combiner将在处理结束时将所有容器合并为一个。

然而,桑德·马克指出,使用现有的Collectors类的预定义Collector groupingBy更容易,将toLowerCase()调用和collect()调用与此结合:

.collect(Collectors.groupingBy(String::toLowerCase, Collectors.counting()));

为了进一步简化代码,你可以将这两个语句合并成一个,通过以下方式:

  • 从返回值和赋值Map<String,Long> =中移除

  • collect调用的末尾去除分号

  • entrySet()调用中移除.map()

那么,你可以说你在一个单独的 Java 语句中实现了一些有用的东西!

9.5 使用并行流和集合提高吞吐量

问题

你希望将Stream与并行性结合,并且仍然能够使用非线程安全的集合 API。

解决方案

使用并行流。

讨论

标准的集合类,如大多数ListSetMap实现,对于更新而言是不线程安全的;如果在一个线程中向其中添加或删除对象,而另一个线程同时访问存储在集合中的对象,则会导致失败。多个线程从同一个集合中读取而不修改是可以的。我们在第十六章中讨论了多线程。

集合框架提供了同步包装器,这些包装器提供自动同步,但会增加线程竞争,从而降低并行性。为了实现高效的操作,并行流允许您安全地使用非线程安全的集合,只要您在操作集合时不修改它。

要使用并行流,只需请求集合,使用parallelStream()而不是我们在 Recipe 9.3 中使用的stream()方法。

例如,假设我们的相机业务起飞了,我们需要快速(而且比之前的代码更少)找到类型和价格范围内的相机:

    public static void main(String[] args) {
        System.out.println("Search Results using For Loop");
        for (Object camera : privateListOfCameras.parallelStream(). ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
                filter(c -> c.isIlc() && c.getPrice() < 500).       ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
                toArray()) {                                        ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
            System.out.println(camera);                             ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        }

        System.out.println(
            "Search Results from shorter, more functional approach");
        privateListOfCameras.parallelStream().                      ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
                filter(c -> c.isIlc() && c.getPrice() < 500).
                forEach(System.out::println);
    }

1

Camera对象的List创建并行流。流的最终结果将由 foreach 循环迭代。

2

使用相同的Predicate lambda 在价格上过滤相机,就像我们在 Recipe 9.1 中使用的一样。

3

通过将Stream转换为数组来终止它。

4

foreach 循环的主体:从Stream中打印一个Camera

5

更简洁地编写搜索的方法。

警告

只要没有线程在搜索进行时同时修改数据,这是可靠的。请查看第十六章中的线程交锁机制,了解如何确保这一点。

9.6 将现有代码作为方法引用使用函数式

问题

您有一个与函数接口匹配的现有代码,并且希望在不重命名方法以匹配接口名称的情况下使用它。

解决方案

使用函数引用,例如MyClass::myFuncsomeObj::someFunc

讨论

引用这个词在 Java 中几乎像Session这个词一样被过载。考虑以下情况:

  • 普通对象通常通过引用访问。

  • 诸如 WeakReference 的引用类型已定义了垃圾收集的语义。

  • 现在,来点完全不同的东西,Java 8 允许您引用单个方法。

  • 您甚至可以引用 Oracle 文档中称为“特定类型的任意对象的实例方法”。

新语法由对象或类名、两个冒号和可以在对象或类名上下文中调用的方法名组成(根据 Java 的常规规则,类名可以引用静态方法,实例可以引用实例方法)。要将构造函数引用为方法,可以使用new,例如MyClass::new。该引用创建了一个可以调用的 lambda,存储在函数接口类型的变量中,等等。

在示例 9-4 中,我们创建了一个 Runnable 引用,它保存的不是通常的 run 方法,而是一个具有相同类型和参数但名称为 walk 的方法。请注意,使用 this 作为方法引用的对象部分。然后,我们将此 Runnable 传递给 Thread 构造函数并启动线程,结果是 walk 被调用,而通常情况下会调用 run

示例 9-4. main/src/main/java/functional/ReferencesDemo.java
/** "Walk, don't run" */
public class ReferencesDemo {

    // Assume this is an existing method we don't want to rename
    public void walk() {
        System.out.println("ReferencesDemo.walk(): Stand-in run method called");
    }

    // This is our main processing method; it runs "walk" in a Thread
    public void doIt() {
        Runnable r = this::walk;
        new Thread(r).start();
    }

    // The usual simple main method to start things off
    public static void main(String[] args) {
        new ReferencesDemo().doIt();
    }
}

输出如下:

ReferencesDemo.walk(): Stand-in run method called

示例 9-5 创建了一个AutoCloseable,以便在 try-with-resources 中使用。通常的AutoCloseable方法是close(),但我们的方法名为cloz()AutoCloseable引用变量autoCloseabletry语句内创建,因此当主体完成时将调用其类似关闭的方法。在此示例中,我们位于静态main方法中,其中有一个对该类实例的引用rnd2,因此我们在引用AutoCloseable兼容方法时使用了它。

示例 9-5. main/src/main/java/functional/ReferencesDemo2.java
public class ReferencesDemo2 {
    void cloz() {
        System.out.println("Stand-in close() method called");
    }

    public static void main(String[] args) throws Exception {
        ReferencesDemo2 rd2 = new ReferencesDemo2();

        // Use a method reference to assign the AutoCloseable interface
        // variable "ac" to the matching method signature "c" (obviously
        // short for close, but just to show the method name isn't what matters).
        try (AutoCloseable autoCloseable = rd2::cloz) {
            System.out.println("Some action happening here.");
        }
    }
}

输出如下:

Some action happening here.
Stand-in close() method called

当然,您可以将其与您自己定义的函数接口一起使用,如“自定义函数接口”所述。您可能至少模糊意识到,任何普通的 Java 对象引用都可以传递给 System.out.println(),并且您将得到对所引用对象的某些描述。示例 9-6 探讨了这两个主题。我们定义了一个名为FunInterface的函数接口,其方法带有一堆参数(仅仅是为了避免它被误认为是任何现有的函数接口)。方法名为process,但如今您知道,名称并不重要;我们的实现方法名为workwork方法是静态的,因此即使方法名相同,我们也不能声明该类implements FunInterface(静态方法不能隐藏继承的实例方法),但我们仍然可以创建对work方法的 lambda 引用。然后我们将其打印出来,以展示它作为 Java 对象具有有效的结构。

示例 9-6. main/src/main/java/functional/ReferencesDemo3.java
public class ReferencesDemo3 {

    interface FunInterface {
        void process(int i, String j, char c, double d);
    }

    public static void work(int i, String j, char c, double d){
        System.out.println("Moo");
    }

    public static void main(String[] args) {
        FunInterface sample = ReferencesDemo3::work;
        System.out.println("My process method is " + sample);
    }
}

这生成了以下输出:

My process method is functional.ReferencesDemo3$$Lambda$1/713338599@4a574795

名称中的Lambda$1在结构上类似于匿名内部类中使用的$1

第四种方式,“特定类型的任意对象的实例方法”,可能是 Java 8 中最神秘的东西。它允许您声明对实例方法的引用,但不指定哪个实例。因为没有特定的实例在脑海中,所以再次使用类名。这意味着您可以在给定类的任何实例上使用它!在示例 9-7 中,我们有一个要排序的String数组。因为该数组中的名称可以以小写字母开头,我们希望使用String方法compareToIgnoreCase()进行排序,这个方法很好地忽略了大小写差异。

因为我想展示几种不同的排序方式,所以我设置了两个数组引用,一个是原始的未排序数组,另一个是重新创建、排序并使用简单的转储例程打印的工作数组(未显示出来,它只是一个for循环打印传递的数组中的字符串)。

示例 9-7. main/src/main/java/functional/ReferencesDemo4.java
import java.util.Arrays;
import java.util.Comparator;

public class ReferencesDemo4 {

    static final String[] unsortedNames = {
        "Gosling", "de Raadt", "Torvalds", "Ritchie", "Hopper"
    };

    public static void main(String[] args) {
        String[] names;

        // Sort using
        // "an Instance Method of an Arbitrary Object of a Particular Type"
        names = unsortedNames.clone();
        Arrays.sort(names, String::compareToIgnoreCase);                    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        dump(names);

        // Equivalent Lambda:
        names = unsortedNames.clone();
        Arrays.sort(names, (str1, str2) -> str1.compareToIgnoreCase(str2)); ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        dump(names);

        // Equivalent old way:
        names = unsortedNames.clone();
        Arrays.sort(names, new Comparator<String>() {                       ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
            @Override
            public int compare(String str1, String str2) {
                return str1.compareToIgnoreCase(str2);
            }
        });
        dump(names);

        // Simpest way, using existing comparator
        names = unsortedNames.clone();
        Arrays.sort(names, String.CASE_INSENSITIVE_ORDER);                  ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        dump(names);
    }

1

使用“特定类型的任意对象的实例方法”,声明了对调用中任何StringcompareToIgnoreCase方法的引用。

2

展示了等效的 Lambda 表达式。

3

展示了“你祖父母的 Java”处理事情的方式。

4

直接使用导出的Comparator,只是为了展示事情总有多种解决方法。

为了安全起见,我运行了演示,并获得了预期的输出:

Amdahl, de Raadt, Gosling, Hopper, Ritchie, Turing
Amdahl, de Raadt, Gosling, Hopper, Ritchie, Turing
Amdahl, de Raadt, Gosling, Hopper, Ritchie, Turing
Amdahl, de Raadt, Gosling, Hopper, Ritchie, Turing

9.7 Java 混入:混合方法

问题

您已经听说过混入(mixins),并希望在 Java 中应用它们。

解决方案

使用静态导入。或者,声明一个或多个包含执行代码的默认方法的功能接口,并简单地实现它。

讨论

来自其他语言的开发人员有时会嘲笑 Java 无法处理混入,即从其他类中混入代码段的能力。

实现混入的一种方法是使用静态导入功能,该功能已经在语言中存在了十年。这经常在单元测试中执行(见食谱 1.10)。此方法的限制是,正如其名称所示,方法必须是静态方法,而不是实例方法。

较新的机制依赖于 Java 8 语言更改的一个有趣的副产品:现在可以将来自不相关位置的代码混入一个类中。Java 是否终于放弃了对多继承的坚决反对?当你第一次听到时可能会这样认为,但放松:你只能从多个接口中获取方法,而不能从多个类中获取。如果你不知道接口中可以定义方法(而不仅仅是声明),请参阅“子类、抽象类或接口?”。考虑以下示例:

main/src/main/java/lang/MixinsDemo.java

interface Bar {
    default String filter(String s) {
        return "Filtered " + s;
    }
}

interface Foo {
    default String convolve(String s) {
        return "Convolved " + s;
    }
}

public class MixinsDemo implements Foo, Bar{

    public static void main(String[] args) {
        String input = args.length > 0 ? args[0] : "Hello";
        String output = new MixinsDemo().process(input);
        System.out.println(output);
    }

    private String process(String s) {
        return filter(convolve(s)); // methods mixed in!
    }
}

如果我们运行这个,我们将看到预期的结果:

C:\javasrc>javac -d build lang/MixinsDemo.java
C:\javasrc>java -cp build lang.MixinsDemo
Filtered Convolved Hello

C:\javasrc>

Presto — Java 现在支持 mixin!

这意味着你应该疯狂地尝试在接口中构建带有代码的功能吗?不是的。请记住,这个机制被设计用来做以下几件事:

  • 提供用于 lambda 计算的功能接口概念。

  • 允许为接口添加新方法,而不必改动旧的实现。和 Java 多年来的许多改变一样,向后兼容性是一个重要驱动因素。

如果适度使用,功能接口可以提供一种通过代码混入来构建应用程序的能力,这种方式不同于直接继承、聚合或 AOP。如果过度使用,会使你的代码变得臃肿,让那些 Java 8 之前的开发者发疯,并导致混乱。

¹ 使用 Haskell 编写了一个相当完整的 Twitter 克隆版本,代码只有几百行;请参阅https://github.com/Gabriel439/simple-twitter

² 如果你曾经需要在关系数据库中使用 Java Persistence API(JPA)存储数据,你应该考虑使用Spring DataApache DeltaSpike框架。这些框架允许你定义一个带有像findCameraByInterchangeableTrueAndPriceLessThan(double price)这样方法名的接口,并让框架为你实现这些方法。

³ 如果你对相机不感兴趣,那么“可换镜头相机(ILC)”的描述包括你在相机商店可能找到的两类产品:传统的单反相机(DSLR)和较新的“紧凑系统相机”,如尼康 1 和 Z 系列、索尼 ILCE(前身为 NEX)和佳能 EOS-M,这些都比旧款 DSLR 相机更小更轻。

第十章:输入和输出:读取、写入和目录技巧

10.0 介绍

大多数程序都需要与外部世界进行交互,其中一种常见的方式是读取和写入文件。文件通常位于某种持久介质上,如磁盘驱动器;大部分时间,我们将忽略硬盘上文件(以及所有操作系统相关的文件系统类型)、USB 驱动器或 SD 卡、DVD-ROM 和其他存储设备之间的差异。就目前而言,它们只是文件。而且,与大多数其他语言和操作系统一样,Java 将读取和写入模型扩展到网络(套接字)通信,我们将在第 12 和 13 章中进行讨论。

Java 提供了许多用于输入和输出的类;它们在图 10-1 中进行了总结。本章涵盖了所有常规的输入/输出操作,如打开/关闭和读取/写入文件。假设文件位于某种文件存储或永久存储中。假定分布式文件系统(如 Apache Hadoop HDFS、Sun 的 Network File System(NFS,在 Unix 上很常见,并且可用于 Windows)、SMB(Windows 网络文件系统,通过开源的 Samba 程序可用于 Unix)、以及 FUSE(Filesystem in User SpacE,在大多数 Unix/Linux 系统上都有实现))与磁盘文件系统的工作方式相同,除非另有说明。

读取和写入的支持主要分为两个部分:

  • InputStream/OutputStream/Reader/Writer 类,这些是传统的读取/写入文件的方式,自 Java 1.0 和 1.1 以来基本未变。在现代 Java 中,提供了一个新类,java.nio.file.Files

  • 所有现代操作系统都提供了将文件组织成目录或文件夹的手段。本章涵盖了目录:如何创建它们,如何浏览它们。Files提供了大部分处理目录的支持,但它也引入了一些方便的例程,用于轻松读取、写入和复制文件,这些内容在本章中进行了讨论。这些通常比使用传统的 I/O 类更方便。我们在本章中都进行了介绍。

警告

术语有两种不同的用法。第一种是用于读取或写入的字节流,与第二种用法无关,后者用于现代 Java 中指的是协作方法之间的连接。我将尽量保持这些含义的准确性,只使用InputStream和/或OutputStream来表示前者,而使用Stream来表示后者。

为了让你能够控制读取和写入数据的格式,FormatterScanner 类提供了格式化和扫描操作。Formatter 允许执行许多格式化任务,可以输出到 String 或几乎任何输出目的地。Scanner 解析许多种类的对象,同样可以从 String 或几乎任何输入源获取。这些都非常强大;本章为每个类都提供了详细的说明。

本章的第二部分主要介绍了 java.nio.file 中的 FilesPath 类。这两个类提供了列出目录、获取文件状态、重命名和删除磁盘上的文件、创建目录以及执行其他文件系统操作的能力。它们还提供了逐行读取文件内容到 Stream<String> 的能力。这两个类共同主要替代了较旧的 java.io.File 类。它们在 Java 7 中引入,因此很少有新代码应该使用旧的 File 类。

注意,这个类的许多方法试图修改计算机上的永久文件存储或磁盘文件系统。当然,你可能没有权限以某种方式更改某些文件。这可以被 Java 虚拟机的 SecurityManager 检测到,如果你没有权限执行尝试的操作,它会抛出未检查的异常 SecurityException。但是如果安全管理器批准了操作,但运行程序的用户在目录上缺少权限,操作系统也可以检测到失败,例如返回一个指示(如 false)或一个受检异常 IOException 的实例。必须在调用任何尝试更改文件系统的方法的任何代码中捕获(或在 throws 子句中声明)此异常。

10.1 关于 InputStreams/OutputStreams 和 Readers/Writers

Java 提供了两组用于读取和写入的类。java.io 包中的 InputStream/OutputStream 部分(参见 图 10-1)用于读取或写入数据字节。早期的语言倾向于假设字节(一种机器特定的位集合,在现代计算机上通常是八位)与字符——字母、数字或其他语言元素——完全相同。然而,Java 设计用于国际化使用,八位并不足以处理世界各地使用的许多不同字符集。基于脚本的语言和象形文字语言如中文和日文,每种语言有超过 256 个字符,这是八位字节能表示的最大数量。这些多种字符编码集的统一称为 Unicode,Java 和 XML 都使用 Unicode 作为其字符集,允许你读写任何这些人类语言的文本。但是对于文本数据应该使用 ReaderWriter,而不是 Stream

Unicode 本身并不能解决所有问题。在 Unicode 发明之前,许多人类语言已经在计算机上使用,它们并不都选择了与 Unicode 相同的表示形式。它们都有大量以特定非 Unicode 编码表示的文件。因此,在读写时需要一些例程来在 Java 虚拟机内部使用 Unicode String 对象和用户文件写入的特定外部表示之间进行转换。这些转换器被打包在一个强大的称为 ReaderWriter 的类集合中。在处理字符而不是字节时,应始终使用 ReaderWriter 而不是 InputStreamOutputStream。我们稍后在本章节将更详细地看到这种转换及如何指定转换。

jcb4 1001

图 10-1. java.io 类

参见

这里没有深入讨论 Java “新 I/O” 包中 Channel 类的读写能力。NIO 的这一部分比 Files 或输入/输出流更复杂,主要在大规模服务器端处理中获益。Recipe 4.5 提供了使用 NIO 的一个例子。Ron Hitchens 的书 Java NIO 对 NIO 包进行了全面覆盖(O’Reilly)。

另一个在此处未涵盖的主题是在读取或写入时与其他程序活动同时发生。这需要使用线程或单个程序内的多个控制流。多线程 I/O 在许多程序中是必需的:那些从缓慢设备(如磁带驱动器)读取的程序,那些从或向网络连接读取或写入的程序,以及那些具有 GUI 的程序。因此,在多线程应用程序的上下文中,这一主题受到了相当的关注,详见第十六章。

对于传统 I/O 主题,虽然有些过时,Elliotte Rusty Harold 的书 Java I/O 应被视为倒数第二的文档。倒数第一参考文献是 javadoc 文档,而最终的参考文献是,如果确实需要,Java API 的源代码。部分因为 javadoc 文档的质量,写作本章时我并未需要参考源代码。

10.2 读取文本文件

问题

Java 文档没有打开文件的方法。如何打开并读取文本文件,然后逐行处理,或者获取所有行的集合?

解决方案

使用 Files::lines() 方法,该方法返回一个 StreamString。或者,使用 Files.newBufferedReader()Files.newBufferedWriter()Files.newInputStream()Files.newOutputStream()。或者构造一个 FileReaderFileInputStream。一旦您拥有这些,构造一个 BufferedReader,并使用旧的 $$while ((line == readLine()) != null)$$ 模式。

讨论

没有显式的打开操作,² 或许是 Java API 面向对象设计的一种修辞华丽。

逐行处理文本文件的最快方法是使用 Files.lines(),该方法接受 Path 参数并返回一个功能性 Stream<String>,它将文件中的行输入其中:

Files.lines(Path.of("myFile.txt")).forEach(System.out::println);

Files 类还有几个静态方法,可以打开文件并读取部分或全部内容:

List<String> Files.readAllLines(Path)

将整个文件读入 List<String>

byte[] Files.readAllBytes

将整个文件读入字节数组。

存在一系列类似 newReader()newBufferedWriter() 等方法,每个方法接受一个 Path 参数并返回相应的 Reader/WriterInputStream/OutputStreamPath 是一个描述可能存在或不存在的抽象路径(文件名)的描述符。FileReaderFileWriterFileInputStreamFileOutputStream 的显式构造函数接受文件名或包含路径的较旧 File 类的实例。这些操作对应于大多数其他语言 I/O 包中的“打开”操作。

在历史上,Java 曾要求使用代码模式 while ((line == readLine()) != nullBufferedReader 中读取行。当然,这仍然有效,并将在 JavaBean 西部设置到位的遥远未来继续有效。

示例 10-1 展示了从文件中读取行的每种方式的代码。

示例 10-1. main/src/main/java/io/ReadLines.java(从文件中读取行)
        System.out.println("Using Path.lines()");
        Files.lines(Path.of(fileName)).forEach(System.out::println);

        System.out.println("Using Path.readAllLines()");
        List<String> lines = Files.readAllLines(Path.of(fileName));
        lines.forEach(System.out::println);

        System.out.println("Using BufferedReader.lines().forEach()");
        new BufferedReader(new FileReader(fileName)).lines().forEach(s -> {
            System.out.println(s);
        });

        System.out.println("The old-fashioned way");
        BufferedReader is = new BufferedReader(new FileReader(fileName));
        String line;
        while ((line = is.readLine()) != null) {
            System.out.println(line);
        }

大多数这些方法可以抛出检查异常 IOException,因此您必须在这些调用周围使用 throws 子句或 try/catch

如果您创建了 InputStreamOutputStreamReaderWriter,在完成后应该关闭它。这样可以避免内存泄漏,并且在写入的情况下确保所有缓冲数据实际写入磁盘。确保不会忘记这一点的一种方法是使用 try-with-resources 语法。这将 Closeable 资源的声明和定义放入 try 语句中:

    static void oldWayShorter() throws IOException {
        try (BufferedReader is =
            new BufferedReader(new FileReader(INPUT_FILE_NAME));
            BufferedOutputStream bytesOut = new BufferedOutputStream(
              new FileOutputStream(OUTPUT_FILE_NAME.replace("\\.", "-1.")));) {

            // Read from is, write to bytesOut
            String line;
            while ((line = is.readLine()) != null) {
                line = doSomeProcessingOn(line);
                bytesOut.write(line.getBytes("UTF-8"));
                bytesOut.write('\n');
            }

        }
    }

Files 中的 lines() 和读取相关方法消除了关闭资源的需要,但没有消除处理 IOException 的需要;如果忘记了这些,编译器或 IDE 将提醒您。

传递给打开文件的 Files 方法的选项是可以选择的;这些选项在侧边栏 “理解 I/O 选项:StandardOpenOptions、FileAttribute、PosixFileAttribute 和更多” 中讨论过。

要在 Java 8+中将文件的整个内容读取为单个字符串,请使用Files.readString()

String input = Files.readString(Path.of(INPUT_FILE_NAME)));

在旧版本的 Java 中,使用我的FileIO.readerToString()方法。这将把整个命名文件读取为一个长字符串,每行之间用嵌入的换行符(\n)分隔。要读取二进制文件,请改用Files.readAllBytes()

参见

有关FilePath的正式在线文档可供查阅。

10.3 从标准输入或控制台/控制终端读取

问题

您希望从程序的标准输入读取,或直接从程序的控制终端或控制台终端读取。

解决方案

对于标准输入,通过在System.in周围包装BufferedInputStream()来读取字节。对于读取文本,使用InputStreamReaderBufferedReader。对于控制台或控制终端,请使用 Java 的System.console()方法获取Console对象,并使用其方法。

讨论

有时确实需要从标准输入或控制台读取。一个原因是简单的测试程序通常是控制台驱动的。另一个原因是某些程序自然需要与用户进行大量交互,并且您希望比 GUI 更快(考虑交互式数学或统计探索程序)。另一个原因是将一个程序的输出直接传递到另一个程序的输入,这在 Unix 用户中非常常见,也在其他支持此操作的平台(例如 Windows)中非常有价值。

标准输入

大多数桌面平台支持标准输入(键盘、文件或另一个程序的输出)和标准输出(终端窗口、打印机、磁盘上的文件或另一个程序的输入)。大多数这种系统还支持标准错误输出,以便用户即使重定向了标准输出,也能看到错误消息。当这些平台上的程序启动时,这三个流被预分配给特定的平台相关句柄或文件描述符。其最终结果是,这些操作系统上的普通程序可以在不需要打开任何文件或进行任何其他特殊安排的情况下读取标准输入或将数据写入标准输出或标准错误流。

Java 延续了这一传统,并将其确立在System类中。静态变量System.inSystem.outSystem.err在程序执行之前与三个操作系统流相连接(应用程序可以重新分配这些;见 Recipe 10.10)。因此,要读取标准输入,只需引用变量System.in并调用其方法。例如,要从标准输入读取一个字节,可以调用System.in的 read 方法,该方法将字节以int变量返回:

int b = System.in.read( );

但这足够吗?不,因为read()方法可能会抛出IOException。因此,您必须声明您的程序可能会抛出IOException

public static void main(String args[]) throws IOException {
...
}

或者您可以在read()方法周围放置一个try/catch块:

        int b = 0;
        try {
            b = System.in.read();
            System.out.println("Read this data: " + (char)b);
        } catch (Exception e) {
            System.out.println("Caught " + e);
        }

在这种情况下,在try块内打印结果是有意义的,因为如果read()抛出IOException,则尝试打印读取的值是没有意义的。

那段代码可以让你从标准输入流中逐字节读取。但是大多数应用程序设计为处理更大的单元,如整数或文本行。要从标准输入中读取已知类型(如int)的值,可以使用Scanner类(详见 Recipe 10.6):

Scanner sc = Scanner.create(System.in);
int i = sc.nextInt();

要使用输入字符转换器读取文本字符,以便程序能够处理全球多种输入编码,可以使用Reader类。特定的子类允许您读取字符行是BufferedReader。但是有一个问题。记得我提到过两种输入类别,StreamReader吗?但我也说过System.in是一个Stream,而你需要一个Reader。如何从Stream获得Reader?有一个专门用于此目的的交叉类叫做InputStreamReader。只需将您的Stream(如System.in)传递给InputStreamReader构造函数,您就会得到一个Reader,然后将其传递给BufferedReader构造函数。在 Java 中写这个通常的习惯用法是嵌套构造函数调用:

BufferedReader is = new BufferedReader(new InputStreamReader(System.in));

然后,您可以使用readLine()方法读取文本行。此方法不接受参数,并返回一个String,由readLine()为您准备,其中包含文件中下一行文本的字符(转换为 Unicode)。当没有更多文本行时,返回字面值null

public class CatStdin {

    public static void main(String[] av) {
        try (BufferedReader is =
                new BufferedReader(new InputStreamReader(System.in))) {
            String inputLine;

            while ((inputLine = is.readLine()) != null) {
                System.out.println(inputLine);
            }
        } catch (IOException e) {
            System.out.println("IOException: " + e);
        }
    }
}

要从标准输入读取单个Integer,请读取一行并使用Integer.parseInt()解析它。要逐行读取一系列整数,可以将这些与函数式风格结合起来,因为BufferedReader有一个生成Stream<String>lines()方法:

public class ReadStdinIntsFunctional {
    private static Stream<Integer> parseIntSafe(String s) {
        try {
            return Stream.of(Integer.parseInt(s));
        } catch (NumberFormatException e) {
            return Stream.empty();
        }
    }

    public static void main(String[] args) throws IOException {
        try (BufferedReader is =
                new BufferedReader(new InputStreamReader(System.in));) {
            is.lines()
                .flatMap(ReadStdinIntsFunctional::parseIntSafe)
                .forEach(System.out::println);
        }
    }
}

控制台(控制终端)

Console类用于直接从程序控制的终端读取。在大多数系统上,从终端窗口命令提示窗口运行应用程序时,默认情况下,其控制台和标准输入都连接到终端。但是,标准输入可以通过大多数操作系统上的管道或重定向进行更改。如果您确实希望从用户所在位置读取,绕过任何间接操作,则Console类通常是您的好帮手。

您不能自己实例化Console;必须从System类的console()方法获取一个实例。然后,您可以调用诸如readLine()之类的方法,其行为基本与前面配方中使用的BufferedReader类中的同名方法相似。

以下代码显示了一个提示输入姓名并从控制台读取的示例:

main/src/main/java/io/ConsoleRead.java

public class ConsoleRead {
    public static void main(String[] args) {
        String name = System.console().readLine("What is your name?");
        System.out.println("Hello, " + name.toUpperCase());
    }
}

一个复杂的情况是,如果控制台未连接,则 System.console() 方法可能返回 null。令人讨厌的是,一些 IDE,包括 Eclipse,在使用“Run As→Java Application”机制时无法设置控制终端。因此,生产质量的代码应始终在尝试使用控制台之前检查 null。如果失败了,请使用记录器或普通的 System.out

Console 类非常有用的一个功能是在不回显密码的情况下读取密码。几十年来,这一直是命令行应用程序的标准功能,因为这是防止“窥视”的最明显方式——有人从你的肩膀上窥视你的密码。现在在 Java 中支持不回显密码读取:Console 类有一个 readPassword() 方法,它接受一个 prompt 参数,应该像这样使用:cons.readPassword("Password:")。此方法返回一个字节数组,可以直接在某些加密和安全 API 中使用,或者可以轻松地转换为 String。通常建议在使用后覆盖字节数组,以防止其他代码可以访问堆栈时发生安全泄漏,尽管当您构造了一个 String 时,这种好处可能会减少。在线代码中有一个示例,在 io/ReadPassword.java 中。

10.4 使用 Formatter 和 printf 打印

问题

你想要一种简单的方法来使用 java.util.Formatter 类的能力来进行简单的打印任务。

解决方案

使用 Formatter 来以精细的控制方式打印值。使用 String.format()PrintWriter.printf() / PrintStream.printf()

讨论

Formatter 类是模仿 C 语言的 printf 函数设计的。实际上,PrintStreamPrintWriter 都有名为 printf() 的便利函数,它们简单地委托给流或写入器的 format() 方法,后者使用默认的 Formatter 实例。然而,与 C 不同,Java 是一种强类型语言,因此无效的参数会抛出异常而不是生成无意义的结果。在 PrintWriter/PrintStream 中还有便利函数 static String.format()printf(),用于在不显式创建 Formatter 的情况下格式化 String

java.util 中的底层 Formatter 类可用于包含格式代码的 String 上。对于每个要格式化的项目,您都要放置一个格式代码。格式代码由一个百分号组成,可选地是一个参数编号后跟一个美元符号,可选地是一个字段宽度或精度,以及一个格式类型(例如,d 表示十进制整数,即没有小数点的整数,f 表示浮点数)。简单的用法可能如下所示:

System.out.printf("%1$04d - the year of %2$f%n", 1956, Math.PI);
System.out.printf("%04d - the year of %f%n", 1956, Math.PI);

正如 图 10-2 所示,“%1\(04d” 控制年份的格式化,而“%2\)f” 控制 PI 的值的格式化。³

jcb4 1002

图 10-2. 格式代码详解

许多格式代码可供选择;表 10-1 列出了一些较常见的代码。完整的描述请参考 java.util.Formatter 的 javadoc。

表 10-1. 格式化器格式代码

代码 含义
c 字符(参数必须是包含有效字符值的 char 或整型类型)。
d “十进制整数”—以十进制(基数 10)打印的整数,没有小数点(参数必须是整型)。
f 带有小数部分的浮点值(必须是数字);字段宽度后面可以跟小数点和小数位字段宽度;例如,7.2f。
e 科学计数法表示的浮点数值。
g 浮点值,根据大小选择 f 或 e。
s 通用格式;如果值为 null,则打印“null”;否则,如果 arg 实现了 Formattable,则按照 arg.formatTo() 格式化;否则按照 arg.toString() 格式化。
t 日期代码;后跟次要代码。常见的日期代码显示在 表 10-2 中。参数必须是 longLongCalendarDate
n 换行;插入特定于平台的换行字符。
% 插入字面上的 % 字符。

还要注意,你可以但并非必须在 % 和格式代码之间放置一个参数顺序数字。例如,在“%2\(04d”中,“2\)”表示格式化第二个参数,而不管参数的顺序如何。这在日期(见下面的示例,在其中需要格式化相同的 DateCalendar 的几个不同部分)和国际化中特别有用,因为不同的语言可能要求句子中的单词以不同的顺序出现。

一些使用 Formatter 的示例显示在 示例 10-2 中。

示例 10-2. main/src/main/java/io/FormatterDemo.java
public class FormatterDemo {
    public static void main(String[] args) {

        // The arguments to all these format methods consist of
        // a format code String and 1 or more arguments.
        // Each format code consists of the following:
        // % - code lead-in
        // N$ - OPTIONAL parameter number (1-based) after the format code
        // N - field width
        // L - format letter (d: decimal(int); f: float; s: general; many more)
        // For the full(!) story, see javadoc for java.util.Formatter.

        // Most general (cumbersome) way of proceding.
        Formatter fmtr = new Formatter();
        Object result = fmtr.format("%1$04d - the year of %2$f", 1956, Math.PI);
        System.out.println(result);
        fmtr.close();

        // Shorter way using static String.format(), default parameter numbering.
        Object stringResult = String.format("%04d - the year of %f", 1956, Math.PI);
        System.out.println(stringResult);

        // A shorter way using PrintStream/PrintWriter.format, more in line with
        // other languages. But this way you should provide the newline delimiter
        // using %n (rather than \n as that is platform-dependent!).
        System.out.printf("%04d - the year of %f%n", 1956, Math.PI);

        // Format doubles with more control
        System.out.printf("PI is approximately %4.2f%n", Math.PI);
    }
}

运行 FormatterDemo 会产生这样的结果:

C:> javac FormatterDates.java
C:> java io.FormatterDates 
1956 - The year of 3.141593
1956 - The year of 3.141593
1956 - The year of 3.141593
PI is about 3.14

对于格式化日期和时间对象,可用的格式代码有很多——总共约有 40 个。日期和时间对象在 第六章 中讨论。表 10-3 显示了更常见的日期/时间格式代码。每个代码前必须带有一个 t,所以要将第一个参数格式化为年份,你会使用 %1$tY

表 10-2. 日期和时间的格式化代码

格式代码 含义
Y 年份(至少四位数字)。
m 月份,两位数字(前导零)。
B 本地化的月份名称(b 表示缩写)。
d 日期的日(两位数字,前导零)。
e 日期的日(一到两位数字)。
A 本地化的星期几(a 表示缩写)。
H 或 I 24 小时制(H)或 12 小时制(I)格式的小时(两位数,前导零)
M 分钟(两位数)
S 秒(两位数)
P/p 区域设置特定的大写 AM 或 PM(如果是 P)或小写(如果是 p)
R 或 T 24 小时制时间组合:%tH:%tM(如果是 R)或%tH:%tM:%tS(如果是 T)
D 格式化为%tm/%td/%ty的日期

在我的观点中,直接将这些代码嵌入到您分发或提供为 Web 应用程序的应用程序中通常是一个坏主意,因为对它们的任何直接使用都假定您知道如何正确地按照全球所有地区的顺序打印这些字段。相信我,你不知道。我建议使用DateTimeFormatter来控制参数的顺序,而不是这些代码,DateTimeFormatter在配方 6.2 中有介绍。然而,对于快速而粗糙的工作,以及写入必须以给定格式存在的日志或数据文件,这些代码是可以的。

一些日期示例显示在示例 10-3 中。

示例 10-3. main/src/main/java/io/FormatterDates.java
public class FormatterDates {
    public static void main(String[] args) {

        // Format number as dates e.g., 2020-06-28
        System.out.printf("%4d-%02d-%2d%n", 2020, 6, 28);

        // Format fields directly from a Date object: multiple fields from "1$"
        // (hard-coded formatting for Date not advisable; see I/O chapter)
        LocalDate today = LocalDate.now();
        // Print in a form like e.g., "July 4, 2020"
        System.out.printf("Today is %1$tB %1$td, %1$tY%n", today);
    }
}

运行这个FormatterDates类将产生以下输出:

C:> java io.FormatterDates
2020-06-28
Today is January 01, 2020

10.5 使用StreamTokenizer扫描输入

问题

您需要以比BufferedReader类及其子类的readLine()方法更细粒度的分辨率扫描文件。

解决方案

使用StreamTokenizerreadLine()StringTokenizerScanner类(见配方 10.6),正则表达式(第四章)或几种第三方解析器生成器之一。

讨论

虽然从理论上讲,您可以逐个字符读取文件并分析每个字符,但这是一个相当底层的方法。Reader类中的read()方法被定义为返回int,以便它可以使用古老的值-1(在 Unix <stdio.h>中定义为 EOF 多年来)来指示您已经读取到文件的末尾:

main/src/main/java/io/ReadCharsOneAtATime.java

public class ReadCharsOneAtATime {

    void doFile(Reader is) throws IOException {
        int c;
        while ((c=is.read( )) != -1) {
            System.out.print((char)c);
        }
    }
}

注意将c转换为char;程序即使没有它也能编译通过,但由于c被声明为int,所以打印不正确。必须将变量c声明为int,以便能够将其与 EOF 值-1进行比较。例如,作为int处理的大写字母 A 对应的整数值为 65,而使用(char)打印字符A

我们在配方 3.1 中广泛讨论了StringTokenizer类。readLine()StringTokenizer的组合提供了一种简单的扫描文件的方法。假设您需要读取一个每行包含一个类似user@host.domain的名称的文件,并且您希望将行分成用户和主机地址。您可以使用这个:

public class ScanStringTok {

    public static void main(String[] av) throws IOException {
        if (av.length == 0)
            System.err.printf("Usage: %s filename [...]%n",
                ScanStringTok.class.getSimpleName());
        else
            for (int i=0; i<av.length; i++)
                process(av[i]);
    }

    static void process(String fileName) {
        String s = null;
        try (BufferedReader is =
                new BufferedReader(new FileReader(fileName));) {
            while ((s = is.readLine()) != null) {
                StringTokenizer st = new StringTokenizer(s, "@", true);
                String user = (String)st.nextElement();
                st.nextElement();
                String host = (String)st.nextElement();
                System.out.println("User name: " + user +
                    "; host part: " + host);

                // Do something useful with the user and host parts...
            }
        } catch (NoSuchElementException ix) {
            System.err.println("Malformed input " + s);
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

java.util中的StreamTokenizer类提供了稍多的能力来扫描文件。它读取字符并将它们组装成单词或标记。它会将这些标记与描述其所找到的标记类型的类型代码一起返回给您。这些类型代码是四种预定义类型之一(StringTokenizer.TT_WORDTT_NUMBERTT_EOF,或TT_EOL用于行尾)。像ordinaryCharacter()这样的方法允许您指定如何分类字符,而像slashSlashComment()这样的方法允许您启用或禁用功能。

示例 10-4 展示了使用StreamTokenizer实现的简单即时模式基于堆栈的计算器:

2 2 + =
4
22 7 / =
3.141592857

我会从StreamTokenizer流中读取标记。数字会被放入堆栈。四个运算符(+-\*/)会立即对堆栈顶部的两个元素执行操作,并将结果放回到堆栈顶部。=运算符会导致顶部元素被打印出来,但仍然留在堆栈中,因此您可以这样说:

4 5 * = 2 / =
20.0
10.0
示例 10-4. main/src/main/java/io/SimpleCalcStreamTok.java(使用 StreamTokenizer 的简单计算器)
public class SimpleCalcStreamTok {
    /** The StreamTokenizer input */
    protected  StreamTokenizer tf;
    /** The output file */
    protected PrintWriter out = new PrintWriter(System.out, true);
    /** The variable name (not used in this version) */
    protected String variable;
    /** The operand stack */
    protected Stack<Double> s = new Stack<>();

    /* Driver - main program */
    public static void main(String[] av) throws IOException {
        if (av.length == 0)
            new SimpleCalcStreamTok(
                new InputStreamReader(System.in)).doCalc();
        else
            for (int i=0; i<av.length; i++)
                new SimpleCalcStreamTok(av[i]).doCalc();
    }

    /** Construct by filename */
    public SimpleCalcStreamTok(String fileName) throws IOException {
        this(new FileReader(fileName));
    }

    /** Construct from an existing Reader */
    public SimpleCalcStreamTok(Reader rdr) throws IOException {
        tf = new StreamTokenizer(rdr);
        // Control the input character set:
        tf.slashSlashComments(true);    // treat "//" as comments
        tf.ordinaryChar('-');        // used for subtraction
        tf.ordinaryChar('/');    // used for division
    }

    /** Construct from a Reader and a PrintWriter
 */
    public SimpleCalcStreamTok(Reader in, PrintWriter out) throws IOException {
        this(in);
        setOutput(out);
    }

    /**
 * Change the output destination.
 */
    public void setOutput(PrintWriter out) {
        this.out = out;
    }

    protected void doCalc() throws IOException {
        int iType;
        double tmp;

        while ((iType = tf.nextToken()) != StreamTokenizer.TT_EOF) {
            switch(iType) {
            case StreamTokenizer.TT_NUMBER: // Found a number, push value to stack
                push(tf.nval);
                break;
            case StreamTokenizer.TT_WORD:
                // Found a variable, save its name. Not used here.
                variable = tf.sval;
                break;
            case '+':
                // + operator is commutative.
                push(pop() + pop());
                break;
            case '-':
                // - operator: order matters.
                tmp = pop();
                push(pop() - tmp);
                break;
            case '*':
                // Multiply is commutative.
                push(pop() * pop());
                break;
            case '/':
                // Handle division carefully: order matters!
                tmp = pop();
                push(pop() / tmp);
                break;
            case '=':
                out.println(peek());
                break;
            default:
                out.println("What's this? iType = " + iType);
            }
        }
    }
    void push(double val) {
        s.push(Double.valueOf(val));
    }
    double pop() {
        return ((Double)s.pop()).doubleValue();
    }
    double peek() {
        return ((Double)s.peek()).doubleValue();
    }
    void clearStack() {
        s.removeAllElements();
    }
}

10.6 使用 Scanner 类扫描输入

问题

您希望扫描一个简单的输入文件,该文件包含已知格式的各种数字和字符串。

解决方案

使用Scannernext()方法读取。

讨论

Scanner类允许您按标记读取输入源,类似于食谱 10.5 中描述的StreamTokenizer。在某些方面,Scanner更灵活(它允许您基于空格或正则表达式分割标记),但在其他方面则不然(您需要知道正在读取的标记类型)。这个类在某些方面与 C 语言的scanf()函数有些相似,但在Scanner中,您通过调用诸如nextInt()nextDouble()等方法来指定输入标记类型。以下是一个简单的扫描示例:

        String sampleDate = "25 Dec 1988";

        try (Scanner sDate = new Scanner(sampleDate)) {
            int dayOfMonth = sDate.nextInt();
            String month = sDate.next();
            int year = sDate.nextInt();
            System.out.printf("%d-%s-%02d%n", year, month, dayOfMonth);
        }

Scanner类能够识别 Java 的八种内置类型,还支持BigIntegerBigDecimal。它还可以将输入标记作为String返回,或者通过匹配正则表达式来返回(见第四章)。表 10-3 列出了“next”方法及其对应的“has”方法;“has”方法返回 true,如果相应的“next”方法将成功。没有nextString()方法;只需使用next()来获取下一个标记作为String

表 10-3. Scanner 方法

返回的类型 “has”方法 “next”方法 注释
String hasNext() next() 从此扫描器中获取下一个完整的标记
String hasNext(Pattern) next(Pattern) 返回与给定正则表达式(regex)匹配的下一个字符串
String hasNext(String) next(String) 返回与指定字符串构造的正则表达式模式匹配的下一个标记
BigDecimal hasNextBigDecimal() nextBigDecimal() 输入中的下一个标记作为 BigDecimal 类型
BigInteger hasNextBigInteger() nextBigInteger() 输入中的下一个标记作为 BigInteger 类型
boolean hasNextBoolean() nextBoolean() 输入中的下一个标记作为 boolean 类型
byte hasNextByte() nextByte() 输入中的下一个标记作为 byte 类型
double hasNextDouble() nextDouble() 输入中的下一个标记作为 double 类型
float hasNextFloat() nextFloat() 输入中的下一个标记作为 float 类型
int hasNextInt() nextInt() 输入中的下一个标记作为 int 类型
String N/A nextLine() 读取直到行尾,包括行尾标记
long hasNextLong() nextLong() 输入中的下一个标记作为 long 类型
short hasNextShort() nextShort() 输入中的下一个标记作为 short 类型

Scanner 类是使用输入源构造的,可以是 InputStreamStringReadableReadableReader 及其所有子类实现的接口)。

使用 Scanner 类的一种方式是基于迭代器模式,使用 while (scanner.hasNext()) 控制迭代。示例 10-5 展示了从 Recipe 10.5(简称)重写的简单计算器,以使用 Scanner 类。

示例 10-5. main/src/main/java/io/simpleCalcScanner.java(使用 java.util.Scanner 的简单计算器)
public class SimpleCalcScanner {
    /** The Scanner */
    protected Scanner scan;

    /** The output */
    protected PrintWriter out = new PrintWriter(System.out, true);

    /** The variable name (not used in this version) */
    protected String variable;

    /** The operand stack; no operators are pushed,
 * so it can be a stack of Double
 */
    protected Stack<Double> s = new Stack<>();

    /* Driver - main program */
    public static void main(String[] args) throws IOException {
        if (args.length == 0)
            new SimpleCalcScanner(
                new InputStreamReader(System.in)).doCalc();
        else
            for (String arg : args) {
                new SimpleCalcScanner(arg).doCalc();
            }
    }

    /** Construct a SimpleCalcScanner by name */
    public SimpleCalcScanner(String fileName) throws IOException {
        this(new FileReader(fileName));
    }

    /** Construct a SimpleCalcScanner from an open Reader */
    public SimpleCalcScanner(Reader rdr) throws IOException {
        scan = new Scanner(rdr);
    }

    /** Construct a SimpleCalcScanner from a Reader and a PrintWriter */
    public SimpleCalcScanner(Reader rdr, PrintWriter pw) throws IOException {
        this(rdr);
        setWriter(pw);
    }

    /** Change the output to go to a new PrintWriter */
    public void setWriter(PrintWriter pw) {
        out = pw;
    }

    protected void doCalc() throws IOException {
        double tmp;

        while (scan.hasNext()) {
            if (scan.hasNextDouble()) {
                push(scan.nextDouble());
            } else {
                String token;
                switch(token = scan.next()) {
                case "+":
                    // Found + operator, perform it immediately.
                    push(pop() + pop());
                    break;
                case "-":
                    // Found - operator, perform it (order matters).
                    tmp = pop();
                    push(pop() - tmp);
                    break;
                case "*":
                    // Multiply is commutative.
                    push(pop() * pop());
                    break;
                case "/":
                    // Handle division carefully: order matters!
                    tmp = pop();
                    push(pop() / tmp);
                    break;
                case "=":
                    out.println(peek());
                    break;
                default:
                    out.println("What's this? " + token);
                    break;
                }
            }
        }
    }

    void push(double val) {
        s.push(Double.valueOf(val));
    }

    double pop() {
        return ((Double)s.pop()).doubleValue();
    }

    double peek() {
        return ((Double)s.peek()).doubleValue();
    }

    void clearStack() {
        s.removeAllElements();
    }
}

10.7 使用语法结构扫描输入

问题

您需要解析一个其结构可以描述为语法的文件(在计算机语言的意义上,而不是自然语言的意义上)。

解决方案

使用多个解析器生成器之一。

讨论

尽管 StreamTokenizer 类(参见 Recipe 10.5)和 Scanner(参见 Recipe 10.6)很有用,它们仅了解有限数量的标记,并且无法指定这些标记必须按特定顺序出现。要执行更高级的扫描,需要一些特殊用途的扫描工具。解析器生成器在计算机科学中有着悠久的历史。最著名的例子是 C 语言中的 yacc(Yet Another Compiler Compiler)和 lex,它们在 1970 年代的第七版 Unix 中发布,并在 lex & yacc 一书中由 Doug Brown 等人(O’Reilly 出版)讨论过,以及它们的开源克隆 bisonflex。这些工具允许您使用诸如正则表达式之类的模式语言指定输入的词法结构。例如,您可以说一个电子邮件地址由一系列字母数字字符组成,后跟一个 at 符号(@),后跟嵌入点的一系列字母数字字符,如下所示:

name:  [A-Za-z0-9]+@[A-Za-z0-0.]
or
name: \w+#[\w.]

该工具然后编写代码来识别你描述的字符。这些工具还有语法规范,例如关键字EMAIL必须出现,后跟一个冒号,然后是先前定义的name标记。

Java 有几个好用的第三方解析器生成工具。它们根据复杂性、功能强大程度和易用性有很大的差异:

  • 最著名和最复杂的之一是ANTLR

  • JavaCC 是一个开源项目,位于https://javacc.org

  • JParsec 允许你在纯 Java 中编写解析器,因此全部都是在编译时构建的(大多数其他工具需要单独的解析生成步骤,这会引起构建和调试问题)。JParsec 位于GitHub上。JFlexCUP共同工作,就像最初的yacclex*一样,作为语法解析器和词法分析器。

  • Parboiled 使用Parsing Expression Grammar(PEG)也在编译时构建解析器。更多信息请参见GitHub。* Rats!解析器生成器是纽约大学的eXTensible Compiler Project的一部分。

  • 还有其他工具;维护在Java Source上的更完整的列表。

这些解析器生成器可以用来编写各种程序的语法,从简单的计算器(例如 Recipe 10.6 中的计算器)到 HTML 和 CORBA/IDL,再到完整的 Java 和 C/C++解析器。这些示例包含在下载中。不幸的是,解析器的学习曲线通常不允许在这里提供一个简单而全面的例子,更不用说智能地比较它们了。请参考每个发行版附带的文档和众多示例。

作为使用这些工具的替代方案,你可以简单地编写自己的递归下降解析器;一旦学会了如何做,你可能会发现这并不是真的那么困难,甚至可能比处理额外的解析器生成软件更少麻烦(显然这取决于所涉及语法的复杂性)。

Java 开发人员有多种选择,包括使用StringTokenizer进行逐行扫描、使用StreamTokenizer进行基于标记的扫描、使用Scanner类来扫描简单标记(参见 Recipe 10.6)、使用正则表达式(参见 Chapter 4)以及基于这里列出的解析工具的基于语法的扫描器的第三方解决方案。

10.8 复制文件

问题

你需要完整地复制一个文件。

解决方案

使用 Java 11 的Files.copy()方法之一。如果使用旧版本,请使用Readers/WriterInputStream/OutputStream中的显式读取和写入方法。

讨论

Files类有几个重载的copy方法,可以快速完成这个需求:

Path copy(Path, Path, CopyOption...) throws java.io.IOException;
long copy(InputStream, Path, CopyOption...) throws IOException;
long copy(Path, OutputStream) throws IOException;

例如:

Path p = Paths.get("my_new_file");
InputStream is = // open some file for reading
long newFileSize = Files.copy(is, p);

很久以前,Java 的 I/O 设施并没有打包很多常见操作,比如复制一个文件到另一个文件或将文件读取到一个String中。所以那时我编写了自己的助手方法包。旧版 JDK 的用户可能希望使用我工具包com.darwinsys.util中的FileIO。这里有一个简单的演示程序,使用FileIO将源文件复制到备份文件:

main/src/demo/java/io/FileIoDemo.java

package com.darwinsys.io;

import java.io.IOException;

public class FileIoDemo {
    public static void main(String[] av) {
        try {
            FileIO.copyFile("FileIO.java", "FileIO.bak");
            FileIO.copyFile("FileIO.class", "FileIO-class.bak");
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

我的copyFile方法有几种形式,取决于你是否有两个文件名,一个文件名和一个PrintWriter等等。FileIO本身的代码没有显示在这里,但可以在线上找到,在 darwinsys API 下载中。

10.9 重新分配标准流

问题

你需要重新分配一个或多个标准流System.inSystem.outSystem.err

解决方案

根据需要构造一个适当的InputStreamPrintStream,并将其传递给System类中的适当设置方法。

讨论

能够重新分配这些流对应于 Unix(或 DOS 命令行)用户所考虑的 重定向管道。这种机制通常用于使程序从文件中读取或写入内容,而无需显式打开文件并逐行更改读取、写入、打印等调用以引用不同的流对象。在 Unix 或 DOS 的命令行解释器中执行打开操作或在 Java 中调用的类中执行。

虽然你可以简单地将一个新的PrintStream赋给变量System.out,但最佳实践是使用定义的方法来替换它:

        String LOGFILENAME = "error.log";
        System.setErr(new PrintStream(new FileOutputStream(LOGFILENAME)));
        System.out.println("Please look for errors in " + LOGFILENAME);
        // Now assume this is somebody else's code; you'll see it
        //   writing to stderr...
        int[] a = new int[5];
        a[10] = 0;    // here comes an ArrayIndexOutOfBoundsException

你使用的流可以是你打开的,如此处,也可以是你继承的:

System.setErr(System.out);    // merge stderr and stdout to same output file.

它也可能是连接到或从另一个Process(参见 Recipe 18.1)的流,一个网络套接字,或一个 URL。任何提供流的东西都可以使用。

10.10 在写入时复制流;重新分配标准流

问题

你希望任何写入流的内容,比如标准输出System.out或标准错误System.err,既出现在那里,被记录到文件中。

解决方案

子类化PrintStream并使其write()方法写入两个流。然后使用system.setErr()setOut()来用一个PrintStream子类替换现有的标准流。

讨论

一些类是为了被子类化而设计的。在这里,我们只是子类化PrintStream并添加了一些功能:第二个PrintStream!我写了一个名为TeePrintStream的类,取名自古老的 Unix 命令 tee。那个命令允许你在两个程序之间的管道上复制或“tee off”(来自水管工的管道 tee,而不是高尔夫比赛或本地害虫)正在写入的数据的副本。

最初的 Unix tee命令用法如下:|字符创建了一个管道,其中一个程序的标准输出成为下一个程序的标准输入。这个经常使用的管道示例展示了有多少用户登录到 Unix 服务器:

who | wc -l

运行who程序(该程序列出已登录系统的用户,每行一个用户,以及终端端口和登录时间),并将其输出发送到wc程序的标准输入,而不是终端。这里,wc被要求计算行数,而不是单词数,因此使用了-l选项。要将中间数据的副本tee到文件中,可以这样说:

who | tee wholist | wc -l

创建包含数据的文件wholist。对于感兴趣的人,文件wholist可能看起来像这样:

ian      ttyC0    Mar 14 09:59
ben      ttyC3    Mar 14 10:23
ian      ttyp4    Mar 14 13:46  (laptop.darwinsys.com)

因此,前面两个命令序列的输出将打印3

TeePrintStream试图捕捉tee命令的精髓。可以像这样使用它:

System.setErr(new TeePrintStream(System.err, "err.log"));
// ...lots of code that occasionally writes to System.err... Or might.

System.setErr()是指定将打印到System.err的文本输出目标的方法(还有System.setOut()System.setIn())。此代码导致打印到System.err的任何消息都打印到先前指定的位置(通常是终端,但可能是 IDE 中的文本窗口),以及文件err.log

这种技术不仅限于三个标准流。可以将TeePrintStream传递给任何需要PrintStream的方法。或者,也可以适应于BufferedInputStreamsPrintWritersBufferedReaders等。

示例 10-6 展示了TeePrintStream的源代码。

示例 10-6. main/src/main/java/io/TeePrintStream.java
public class TeePrintStream extends PrintStream {
    /** The original/direct print stream */
    protected PrintStream parent;

    /** The filename we are tee-ing too, if known;
 * intended for use in future error reporting.
 */
    protected String fileName;

    /** The name for when the input filename is not known */
    private static final String UNKNOWN_NAME = "(opened Stream)";

    /** Construct a TeePrintStream given an existing PrintStream,
 * an opened OutputStream, and a boolean to control auto-flush.
 * This is the main constructor, to which others delegate via "this".
 */
    public TeePrintStream(PrintStream orig, OutputStream os, boolean flush)
    throws IOException {
        super(os, true);
        fileName = UNKNOWN_NAME;
        parent = orig;
    }

    /** Construct a TeePrintStream given an existing PrintStream and
 * an opened OutputStream.
 */
    public TeePrintStream(PrintStream orig, OutputStream os)
    throws IOException {
        this(orig, os, true);
    }

    /* Construct a TeePrintStream given an existing Stream and a filename.
 */
    public TeePrintStream(PrintStream os, String fn) throws IOException {
        this(os, fn, true);
    }

    /* Construct a TeePrintStream given an existing Stream, a filename,
 * and a boolean to control the flush operation.
 */
    public TeePrintStream(PrintStream orig, String fn, boolean flush)
    throws IOException {
        this(orig, new FileOutputStream(fn), flush);
        fileName = fn;
    }

    /** Return true if either stream has an error. */
    public boolean checkError() {
        return parent.checkError() || super.checkError();
    }

    /** override write(). This is the actual "tee" operation. */
    public void write(int x) {
        parent.write(x);    // "write once;
        super.write(x);        // write somewhere else."
    }

    /** override write(). This is the actual "tee" operation. */
    public void write(byte[] x, int o, int l) {
        parent.write(x, o, l);    // "write once;
        super.write(x, o, l);    // write somewhere else."
    }

    /** Close both streams. */
    public void close() {
        parent.close();
        super.close();
    }

    /** Flush both streams. */
    public void flush() {
        parent.flush();
        super.flush();
    }
}

值得一提的是,我需要重写所有print()println()的多态形式。因为这些方法最终都使用write()的形式之一,如果你重写printprintln方法来进行tee,你可以得到多个额外的数据副本输出。

10.11 读取/写入不同字符集

问题

您需要使用特定编码读取或写入文本文件。

解决方案

当你构造一个InputStreamReaderPrintWriter时,通过指定转换器可以将文本转换为或从内部 Unicode 格式转换出来。

讨论

InputStreamReaderOutputStreamWriter 类是从字节导向的 Stream 到基于字符的 Reader 的桥梁。这些类读取或写入字节并根据指定的字符编码将其转换为字符,或者从字符转换为字节。在 Java 中使用的 UTF-16 字符集(charString 类型)是一个 16 位字符集。但是,大多数字符集(如 ASCII、瑞典语、西班牙语、希腊语、土耳其语等)仅使用该字符集的一个小子集。事实上,许多欧洲语言字符集完全适合 8 位字符。即使是更大的字符集(基于脚本和象形文字的语言),其每个特定字符并不都使用相同的位值。因此,编码是 Java 字符与从特定国家或语言字符集中提取的字符的外部存储格式之间的映射。

为简化事务,InputStreamReaderOutputStreamWriter 构造函数是此转换中唯一可以指定编码名称的地方。如果不指定编码,将使用平台(或用户)的默认编码。PrintWritersBufferedReaders 等都使用 InputStreamReaderOutputStreamWriter 类使用的编码。由于这些桥接类仅在其构造函数中接受 Stream 参数,所以如果要指定非默认转换器以读取或写入磁盘上的文件,必须从构造FileReaderFileWriter 开始,而不是从FileInputStreamFileOutputStream 开始!

// io/UseConverters.java
BufferedReader fromKanji = new BufferedReader(
    new InputStreamReader(new FileInputStream("kanji.txt"), "EUC_JP"));
PrintWriter toSwedish = new PrinterWriter(
    new OutputStreamWriter(new FileOutputStream("sverige.txt"), "Cp278"));

并不是说从日语单个文件读取并以瑞典编码输出就一定有意义。首先,大多数字体并不包含两个字符集中的所有字符;而且,瑞典编码中的字符数肯定比日语编码少得多。此外,如果这是你想要的全部,你可以使用一个名字并不太合适的 JDK 工具 native2ascii(详见其文档)。支持的编码列表也在 JDK 文档中,位于 docs/guide/internat/encoding.doc.html 文件中。更详细的描述可见 Java I/O 的附录 B。

10.12 那些令人讨厌的换行字符

问题

您真的想知道换行字符。

解决方案

使用 \r\n 以合适的组合。

讨论

如果使用 readLine() 方法以行模式读取文本(或包含 ASCII 字符的字节),您将永远看不到换行字符;如果使用带有 println() 方法的 PrintWriter,情况也是如此。因此,您无需费力弄清每行的 \n\r\r\n 的出现。

如果你需要那种详细程度的信息,你必须逐个字符或字节地读取,使用 read() 方法。我发现这种情况必要的唯一时间是在网络编程中,其中一些线路模式协议假定行尾是 \r\n。即使在这种情况下,你仍然可以在行模式下工作。在写入时,将 \r\n 传递给 print()(而不是 + 处理字符:

outputSocket.print("HELO " + myName + "\r\n");
String response = inputSocket.readLine();

对于好奇的人,"hello" 的奇怪拼写用于 SMTP,即邮件发送协议,其中命令是四个字母。

10.13 注意依赖于平台的文件代码

问题

在之前的示例中警觉之后,你现在希望编写仅限于平台的独立代码。

解决方法

使用 readLine()println()。避免单独使用 \n;如果必须使用,使用 File.separator

讨论

如 Recipe 10.12 中提到的,如果你只使用 readLine()println(),就不需要考虑行尾符号。但是一个特别的问题,尤其是对于以前编写过 C 语言及其相关语言的程序员来说,是在文本字符串中使用 \n 字符表示换行。这段代码特别让人不安的是它有时——通常在开发者自己的平台上——能够正常工作。但它可能某一天会在其他系统上失败:

    String myName;
    public static void main(String[] argv) {
        BadNewline jack = new BadNewline("Jack Adolphus Schmidt, III");
        System.out.println(jack);
    }
    /**
 * DON'T DO THIS. THIS IS BAD CODE.
 */
    public String toString() {
        return "BadNewlineDemo@" + hashCode() + "\n" + myName;
    }

    // The obvious Constructor is not shown for brevity; it's in the code

真正的问题不是它在某些平台上失败,虽然如此。真正的问题在于它混合了格式化和输入/输出,或者试图这样做。不要在 toString() 中混合基于行的显示;避免 多行字符串 ——从 toString() 或任何其他返回字符串的方法输出。如果你需要写多个字符串,那就明确表达你的意图:

    String myName;
    public static void main(String[] argv) {
        GoodNewline jack = new GoodNewline("Jack Adolphus Schmidt, III");
        jack.print(System.out);
    }

    protected void print(PrintStream out) {
        out.println(toString());    // classname and hashcode
        out.println(myName);        // print name  on next line
    }

或者,如果你需要多行,你可以返回一个字符串数组或 List

10.14 读取/写入二进制数据

问题

你需要读取或写入二进制数据,而不是文本。

解决方法

使用 DataInputStreamDataOutputStream

讨论

Stream 类自 Java 诞生以来就存在,并且对于读写字节而非字符是最优的。它们的数据层,包括 DataInputStreamDataOutputStream,已配置为读写二进制值,包括 Java 的所有内置类型。假设你想要将一个二进制整数加上一个二进制浮点数值写入文件并稍后读取它。这段代码展示了写入部分:

public class WriteBinary {
    public static void main(String[] argv) throws IOException {
        int i = 42;
        double d = Math.PI;
        String FILENAME = "binary.dat";
        DataOutputStream os = new DataOutputStream(
            new FileOutputStream(FILENAME));
        os.writeInt(i);
        os.writeDouble(d);
        os.close();
        System.out.println("Wrote " + i + ", " + d + " to file " + FILENAME);
    }
}

如果需要写入对象的所有字段,应该使用 Recipe 12.6 中描述的方法之一。

10.15 读取和写入 JAR 或 ZIP 存档

问题

你需要创建和/或从 JAR 存档或文件中提取,在由 PkZip 确定并由 Unix zip/unzip 和 WinZip 使用的众所周知的 ZIP 存档格式中。

解决方法

您可以使用 Java 开发工具包中的jar程序,因为其文件格式与 ZIP 格式相同,并添加了META-INF目录以包含附加的结构信息。但因为这是一本关于编程的书籍,您可能更感兴趣的是ZipFileZipEntry类及其提供访问权限的流类。

讨论

java.util.zip.ZipFile本身不是一个 I/O 类,而是一个实用类,允许您读取或写入 JAR 或 ZIP 格式文件的内容。⁵ 创建时,它会创建一系列ZipEntry对象,每个条目代表存档中的一个条目。换句话说,ZipFile表示整个存档,而ZipEntry表示一个条目或存储(和压缩)在存档中的一个文件。ZipEntry具有诸如getName()(返回文件在放入存档之前的名称)和getInputStream()(提供InputStream,通过透明地将存档条目解压缩来过滤它)等方法。要创建ZipFile对象,您需要存档文件的名称或表示它的File对象:

ZipFile zippy = new ZipFile(fileName);

要查看存档中是否存在给定文件,可以使用带有文件名的getEntry()方法。更常见的是,您可能希望处理所有条目;为此,请使用ZipFile对象获取存档中条目的枚举列表(如本处所示:Recipe 7.6)。

Enumeration all = zippy.entries( );
while (all.hasMoreElements( )) {
    ZipEntry entry = (ZipEntry)all.nextElement( );
    ...
}

然后,我们可以按需处理每个条目。一个简单的列出程序可能是这样的:

if (entry.isDirectory( ))
    println("Directory: " + e.getName( ));
else
    println("File: " + e.getName( ));

更高级的版本将提取文件。例如,示例 10-7 中的程序默认列出文件,但是使用-x(提取)开关时,它实际上会从存档中提取文件。

示例 10-7. main/src/main/java/io/UnZip.java
public class UnZip {
    /** Constants for mode listing or mode extracting. */
    public static enum Mode {
        LIST,
        EXTRACT;
    }
    /** Whether we are extracting or just printing TOC */
    protected Mode mode = Mode.LIST;

    /** The ZipFile that is used to read an archive */
    protected ZipFile zippy;

    /** The buffer for reading/writing the ZipFile data */
    protected byte[] b = new byte[8092];

    /** Simple main program, construct an UnZipper, process each
 * .ZIP file from argv[] through that object.
 */
    public static void main(String[] argv) {
        UnZip u = new UnZip();

        for (int i=0; i<argv.length; i++) {
            if ("-x".equals(argv[i])) {
                u.setMode(Mode.EXTRACT);
                continue;
            }
            String candidate = argv[i];
            // System.err.println("Trying path " + candidate);
            if (candidate.endsWith(".zip") ||
                candidate.endsWith(".jar"))
                    u.unZip(candidate);
            else System.err.println("Not a zip file? " + candidate);
        }
        System.err.println("All done!");
    }

    /** Set the Mode (list, extract). */
    protected void setMode(Mode m) {
        mode = m;
    }

    /** Cache of paths we've mkdir()ed. */
    protected SortedSet<String> dirsMade;

    /** For a given Zip file, process each entry. */
    public void unZip(String fileName) {
        dirsMade = new TreeSet<String>();
        try {
            zippy = new ZipFile(fileName);
            @SuppressWarnings("unchecked")
            Enumeration<ZipEntry> all = (Enumeration<ZipEntry>) zippy.entries();
            while (all.hasMoreElements()) {
                getFile((ZipEntry)all.nextElement());
            }
        } catch (IOException err) {
            System.err.println("IO Error: " + err);
            return;
        }
    }

    protected boolean warnedMkDir = false;

    /** Process one file from the zip, given its name.
 * Either print the name, or create the file on disk.
 */
    protected void getFile(ZipEntry e) throws IOException {
        String zipName = e.getName();
        switch (mode) {
        case EXTRACT:
            if (zipName.startsWith("/")) {
                if (!warnedMkDir)
                    System.out.println("Ignoring absolute paths");
                warnedMkDir = true;
                zipName = zipName.substring(1);
            }
            // if a directory, just return. We mkdir for every file,
            // since some widely used Zip creators don't put out
            // any directory entries, or put them in the wrong place.
            if (zipName.endsWith("/")) {
                return;
            }
            // Else must be a file; open the file for output
            // Get the directory part.
            int ix = zipName.lastIndexOf('/');
            if (ix > 0) {
                String dirName = zipName.substring(0, ix);
                if (!dirsMade.contains(dirName)) {
                    File d = new File(dirName);
                    // If it already exists as a dir, don't do anything
                    if (!(d.exists() && d.isDirectory())) {
                        // Try to create the directory, warn if it fails
                        System.out.println("Creating Directory: " + dirName);
                        if (!d.mkdirs()) {
                            System.err.println(
                            "Warning: unable to mkdir " + dirName);
                        }
                        dirsMade.add(dirName);
                    }
                }
            }
            System.err.println("Creating " + zipName);
            FileOutputStream os = new FileOutputStream(zipName);
            InputStream  is = zippy.getInputStream(e);
            int n = 0;
            while ((n = is.read(b)) >0)
                os.write(b, 0, n);
            is.close();
            os.close();
            break;
        case LIST:
            // Not extracting, just list
            if (e.isDirectory()) {
                System.out.println("Directory " + zipName);
            } else {
                System.out.println("File " + zipName);
            }
            break;
        default:
            throw new IllegalStateException("mode value (" + mode + ") bad");
        }
    }
}

参见

有时人们会将 ZIP 存档文件格式与同名的 gzip 压缩格式混淆。可以使用java.io中的GZipInputStreamGZipOutputStream类来读取或写入 gzip 压缩文件。

10.16 使用getResource()getResourceAsStream()以文件系统中立的方式查找文件

问题

您希望加载对象或文件,而不需要引用它们在文件系统中的绝对位置。您可能出于以下原因之一希望这样做:

  • 您位于服务器(Java EE)环境中。

  • 您希望独立于文件路径。

  • 您希望在单元测试中读取文件。

  • 您期望用户将资源“放置在某个地方”,在LASSPATH(甚至可能在 JAR 文件内部)中。

解决方案

使用getClass()getClassLoader()以及getResource()getResourceAsStream()

讨论

getResource() 方法有三种变体,其中一些存在于Class类(参见 Chapter 17)和ClassLoader类(参见 Recipe 17.5)中,具有完全相同的签名。Class中的方法委托给ClassLoader,因此它们之间几乎没有区别。方法总结如 Table 10-6 所示。

表 10-6. getResource* 方法

方法签名 在 Class 中 在 ClassLoader 中
public InputStream getResourceAsStream(String); Y Y
public URL getResource(String); Y Y
public Enumeration<URL> getResources(String) throws IOException; N Y

第一个方法旨在快速轻松地定位CLASSPATH上的资源或文件。使用Class版本,或者另一个带有标准ClassLoader实现的版本,资源可以是物理文件或 JAR 文件中的文件。如果你定义自己的类加载器,那么只要它可以表示为InputStream,你的想象力就是限制。这通常用于如下所示:

InputStream is = getClass().getResourceAsStream("foo.properties");
// then do something with the InputStream...

第二种形式返回一个URL,可以以各种方式解释(参见 Recipe 12.1 中有关从 URL 读取的讨论)。

第三种形式,只能与ClassLoader实例一起使用,返回一个Enumeration对象,其中包含一系列URL对象。这意味着返回所有匹配给定字符串的资源;请记住,CLASSPATH可以由任意数量的目录和/或 JAR 文件组成,因此这将搜索所有这些目录。这对于查找一系列配置文件并合并它们可能很有用。或者查找是否在你的CLASSPATH上有多个具有相同名称的资源/文件。

注意资源名称可以是相对路径或绝对路径。假设你在使用 Maven(参见 Recipe 1.7),对于绝对路径,将文件放置在src/main/resources/目录中。对于相对路径,将文件放置在与源代码相同的目录中。在 IDE 中也适用相同的规则,假设你已经在 IDE 配置中将src/main/javasrc/main/resources设置为源文件夹。这样做的目的是为了将资源文件复制到你的CLASSPATH文件夹中。例如,如果你有两个资源文件,src/main/resources/one.txtsrc/main/java/MyPackage/two.txt,并且你的项目已配置如上所述,那么如果从MyPackage中的程序访问,这两行代码将起作用:

Class<?> c = getClass();
InputStream isOne = getResourceAsStream("/one.txt");	// note leading slash
InputStream isTwo = getResourceAsStream("two.txt");	// without leading slash
警告

无论哪种情况,如果getResource()getResourceAsStream()找不到资源,它们都将返回null;你应该始终检查null以防止错误的部署。如果它找不到与之匹配的任何内容,getResources()将返回一个空的Enumeration

如果文件路径在组件之间有斜杠(如 package/subpackage),则在 getResource 方法中将斜杠替换为句点。

10.17 获取文件信息:Files 和 Path

问题

你需要了解磁盘上给定文件的所有内容。

解决方案

使用 java.nio.file.Files 方法。

讨论

java.nio.file.Files 类的名称使用复数形式,既是为了区别于它所替代的遗留 File 类,也是为了提醒我们它有时会处理多个文件。 Files 类中有两种类型的静态方法,信息性方法和操作性方法。信息性方法(参见 表 10-7)仅提供有关单个文件的信息,例如 boolean exists()long size()。操作性方法(参见 表 10-8)则可能会更改文件系统或打开文件以供读取或写入。每个操作性方法都可能抛出已检查的异常 IOException;只有少数信息性方法会抛出异常。

绝大多数这些方法都有 java.nio.file.Path 类型的参数。 Path 表示文件系统中的路径,即一组目录和可能的文件,例如 “C:\Users\user\Downloads” 或 “/home/ian/Downloads”。在创建 Path 表示它时,该路径在磁盘上可能存在,也可能不存在。 Files 类可以告诉您,由给定 Path 表示的文件是否存在,可以将该 Path 创建为文件或目录,并且可以更改相应文件的属性,甚至在存在时销毁它。 Path 对象可以使用 Path.of(String name) 轻松创建,该方法具有多个重载。

FilesPath 结合,几乎提供了编写完整的文件管理器应用所需的一切,更不用说更典型的需要文件信息和/或目录访问的应用程序。 Files 类具有一系列静态的 boolean 方法,提供基本信息。

表 10-7. java.nio.file.Files 中的公共静态信息性方法

返回类型 方法 备注
boolean exists(Path, LinkOption…);
Object getAttribute(Path, String, LinkOption…);
V getFileAttributeView(Path, Class, LinkOption…);
FileTime getLastModifiedTime(Path, LinkOption…);
UserPrincipal getOwner(Path, LinkOption…);
Set getPosixFilePermissions(Path, LinkOption…);
boolean isDirectory(Path, LinkOption…);
boolean isExecutable(Path); 如果当前用户可执行
boolean isHidden(Path); 如果是 Unix 上的“点文件”,或者某些操作系统上设置了“隐藏”属性
boolean isReadable(Path); 如果当前用户可读
boolean isRegularFile(Path, LinkOption…);
boolean isSameFile(Path, Path) throws IOException; 需要处理文件系统的复杂性,如“..”,符号链接等
boolean isSymbolicLink(Path);
boolean isWritable(Path); 如果当前用户可写
long mismatch(Path, Path);
boolean notExists(Path, LinkOption…);
String probeContentType(Path) throws IOException; 尝试返回数据的 MIME 类型
Path readSymbolicLink(Path) throws IOException;
long size(Path);

“当前用户”指的是当前 JVM 实例正在运行的账户。

这些方法大多数在 Example 10-8 中有演示。

Example 10-8. main/src/main/java/io/FilesInfos.java
        println("exists", Files.exists(Path.of("/")));
        println("isDirectory", Files.isDirectory(Path.of("/")));
        println("isExecutable", Files.isExecutable(Path.of("/bin/cat")));
        println("isHidden", Files.isHidden(Path.of("~/.profile")));
        println("isReadable", Files.isReadable(Path.of("lines.txt")));
        println("isRegularFile", Files.isRegularFile(Path.of("lines.txt")));
        println("isSameFile", Files.isSameFile(Path.of("lines.txt"),
            Path.of("../main/lines.txt")));
        println("isSymbolicLink", Files.isSymbolicLink(Path.of("/var")));
        println("isWritable", Files.isWritable(Path.of("/tmp")));
        println("isDirectory", Files.isDirectory(Path.of("/")));
        println("notexists",
            Files.notExists(Path.of("no_such_file_as_skjfsjljwerjwj")));
        println("probeContentType", Files.probeContentType(Path.of("lines.txt")));
        println("readSymbolicLink", Files.readSymbolicLink(Path.of("/var")));
        println("size", Files.size(Path.of("lines.txt")));

显然,所选路径在某种程度上是特定于系统的,但在我的 Unix 系统上运行时,boolean 方法都返回 true,而最后三个返回了这个:

probeContentType returned text/plain
readSymbolicLink returned private/var
size returned 78

Table 10-8 显示了对文件系统实体进行更改的方法。

Table 10-8. java.nio.file.Files 中的公共静态操作方法

返回类型 方法
long copy(InputStream, Path, CopyOption…);
long copy(Path, OutputStream);
Path copy(Path, Path, CopyOption…);
Path createDirectories(Path, FileAttribute<?>…);
Path createDirectory(Path, FileAttribute<?>…);
Path createFile(Path, FileAttribute<?>…);
Path createLink(Path, Path);
Path createSymbolicLink(Path, Path, FileAttribute<?>…);
Path createTempDirectory(Path, String, FileAttribute<?>…);
Path createTempDirectory(String, FileAttribute<?>…);
Path createTempFile(Path, String, String, FileAttribute<?>…);
Path createTempFile(String, String, FileAttribute<?>…);
void delete(Path);
boolean deleteIfExists(Path);
Stream find(Path, int, BiPredicate<Path, BasicFileAttributes>, FileVisitOption…);
Stream lines(Path);
Stream lines(Path, Charset);
Stream list(Path);
路径 move(Path, Path, CopyOption…);
BufferedReader newBufferedReader(Path);
BufferedReader newBufferedReader(Path, Charset);
BufferedWriter newBufferedWriter(Path, Charset, OpenOption…);
BufferedWriter newBufferedWriter(Path, OpenOption…);
SeekableByteChannel newByteChannel(Path, OpenOption…);
SeekableByteChannel newByteChannel(Path, Set, FileAttribute…);
DirectoryStream newDirectoryStream(Path);
DirectoryStream newDirectoryStream(Path, String);
InputStream newInputStream(Path, OpenOption…);
OutputStream newOutputStream(Path, OpenOption…);
byte[] readAllBytes(Path);
List readAllLines(Path);
List readAllLines(Path, Charset);
A readAttributes(Path, Class, LinkOption…);
Map<String, Object> readAttributes(Path, String, LinkOption…);
String readString(Path);
String readString(Path, Charset);
Path setAttribute(Path, String, Object, LinkOption…);
Path setLastModifiedTime(Path, FileTime);
Path setOwner(Path, UserPrincipal);
Path setPosixFilePermissions(Path, Set);
Path write(Path, Iterable<? extends CharSequence>, Charset, OpenOption…);
Path write(Path, Iterable<? extends CharSequence>, OpenOption…);
Path write(Path, byte[], OpenOption…);
Path writeString(Path, CharSequence, Charset, OpenOption…);
Path writeString(Path, CharSequence, OpenOption…);

Path是一个接口,其实现由名为Filesystem的提供者类提供。Path有许多方法,列在表格 10-9 中。

表格 10-9. java.nio.file.Path 中的公共静态操作方法

Access 返回类型 方法
static Path of(String, String…);
static Path of(URI);
abstract FileSystem getFileSystem();
abstract boolean isAbsolute();
abstract Path getRoot();
abstract Path getFileName();
abstract Path getParent();
abstract int getNameCount();
abstract Path getName(int);
abstract Path subpath(int, int);
abstract boolean startsWith(Path);
default boolean startsWith(String);
abstract boolean endsWith(Path);
default boolean endsWith(String);
abstract Path normalize();
abstract Path resolve(Path);
default Path resolve(String);
default Path resolveSibling(Path);
default Path resolveSibling(String);
abstract Path relativize(Path);
abstract URI toUri();
abstract Path toAbsolutePath();
abstract Path toRealPath(LinkOption…) throws IOException;
default File toFile();
abstract WatchKey register(WatchService, WatchEvent\(Kind<?>[], WatchEvent\)Modifier…) throws IOException;
default WatchKey register(WatchService, WatchEvent$Kind<?>…) throws IOException;
default Iterator iterator();
abstract int compareTo(Path);
abstract boolean equals(Object);
abstract int hashCode();
abstract String toString();
default int compareTo(Object);

要查找有关一个文件的信息,您可以使用FilesPath中的信息方法,如示例 10-9 所示。

示例 10-9. main/src/main/java/dir_file/FileStatus.java(获取文件信息)
public class FileStatus {
    public static void main(String[] argv) throws IOException {

        // Ensure that a filename (or something) was given in argv[0]
        if (argv.length == 0) {
            System.err.println("Usage: FileStatus filename");
            System.exit(1);
        }
        for (String a : argv) {
            status(a);
        }
    }

    public static void status(String fileName) throws IOException {
        System.out.println("---" + fileName + "---");

        // Construct a Path object for the given file.
        Path p = Path.of(fileName);

        // See if it actually exists
        if (!Files.exists(p)) {
            System.out.println("file not found");
            System.out.println();    // Blank line
            return;
        }
        // Print full name
        System.out.println("Canonical name " + p.normalize());
        // Print parent directory if possible
        Path parent = p.getParent();
        if (parent != null) {
            System.out.println("Parent directory: " + parent);
        }
        // Check if the file is readable
        if (Files.isReadable(p)) {
            System.out.println(fileName + " is readable.");
        }
        // Check if the file is writable
        if (Files.isWritable(p)) {
            System.out.println(fileName + " is writable.");
        }

        // See if file, directory, or other. If file, print size.
        if (Files.isRegularFile(p)) {
            // Report on the file's size and possibly its type
            System.out.printf("File size is %d bytes, content type %s\n",
                    Files.size(p),
                    Files.probeContentType(p));
        } else if (Files.isDirectory(p)) {
            System.out.println("It's a directory");
        } else {
            System.out.println("I dunno! Neither a file nor a directory!");
        }

        // Report on the modification time.
        final FileTime d = Files.getLastModifiedTime(p);
        System.out.println("Last modified " + d);

        System.out.println();    // blank line between entries
    }

当在 MS Windows 上运行时,使用所示的三个参数,会产生如下输出:

C:\javasrc\dir_file>java dir_file.FileStatus   / /tmp/id /autoexec.bat
---/---
Canonical name C:\
File is readable.
File is writable.
Last modified 1970-01-01T00:00:00.00000Z
It's a directory

---/tmp/id---
file not found

---/autoexec.bat---
Canonical name C:\AUTOEXEC.BAT
Parent directory: \
File is readable.
File is writable.
Last modified 2019-10-13T12:43:05.123918Z
File size is 308 bytes.

正如你所见,所谓的规范名称不仅包括*C:*的前导目录根,还将名称转换为大写。你可以看出我是在 Windows 上运行的。这个版本的 Windows 不保留目录的时间戳;值0L被解释为 1970 年 1 月 1 日(与自 Unix 以来的时间基准相同)。在 Unix 上,它的行为有所不同:

$ java dir_file.FileStatus / /tmp/id /autoexec.bat
---/---
Canonical name /
File is readable.
It's a directory
Last modified 2019-12-16T01:14:05.226108Z

---/tmp/id---
Canonical name /tmp/id
Parent directory: /tmp
File is readable.
File is writable.
File size is 36768 bytes, content type null
Last modified 2019-12-21T18:46:27.402108Z

---/autoexec.bat---
file not found

$

典型的 Unix 系统没有autoexec.bat文件。Unix 文件名(像 Mac 上的那些)可以由大写和小写字符组成:输入什么就是什么。

旧版本兼容性

要在需要旧的java.io.File的旧代码中使用Path,简单地使用File oldType = Path.toFile()

jshell> Path p = Path.of("/");
p ==> /

jshell> File f = p.toFile();
f ==> /

要反向操作,File类已经加入了一个toPath()方法:

jshell> File f = new File("/");
f ==> /

jshell> Path p = f.toPath();
p ==> /

10.18 创建新文件或目录

问题

你需要在磁盘上创建一个新文件,但不写入任何数据;在创建文件之前,你需要创建一个目录。

解决方案

对于空文件,使用java.nio.file.Files对象的createFile(Path)方法。使用Files类的createDirectory()createDirectories()方法来创建目录。

讨论

文件

你可以通过构建FileOutputStreamFileWriter(参见 Recipe 12.6)轻松创建一个新文件。但是你还需要记得关闭它。有时候你希望文件存在,但并不想费力把任何东西放进去。例如,这可以作为一种简单的程序间通信形式使用:一个程序可以测试文件是否存在,并解释为另一个程序已达到某种状态。Example 10-10 是一段代码,简单地为你提供的每个名称创建一个空文件。

Example 10-10. main/src/main/java/dir_file/Creat.java(在磁盘上创建文件)
/** Create file(s) by name. Final "e" omitted in homage to UNIX system call. */
public class Creat {
    public static void main(String[] argv) throws IOException {

        // Ensure that a filename (or something) was given in argv[0]
        if (argv.length == 0) {
            throw new IllegalArgumentException("Usage: Creat filename [...]");
        }

        for (String arg : argv) {
            // Constructing a Path object doesn't affect the disk, but
            // the Files.createFile() method does.
            final Path p = Path.of(arg);
            final Path created = Files.createFile(p);
            System.out.println(created);
        }
    }
}

java.nio.file.createFile()有一个重载方法,接受第二个类型为OpenOption的参数。这是一个空接口,由StandardOpenOption枚举实现。这些选项在 Table 10-5 中列出。

目录

用于创建目录的两种方法中,createDirectory()只创建一个目录,而createDirectories()则创建所需的任何中间目录。例如,如果/home/ian存在且是一个目录,则调用

shell> Files.createDirectory(Path.of("/Users/ian/abc"))
$11 ==> /Users/ian/abc

会成功(除非目录已存在),但调用

jshell> Files.createDirectory(Path.of("/Users/ian/once/twice/again"))

将因为名为once的目录不存在而导致java.nio.file.NoSuchFileException异常。要创建这些目录路径,正如你现在可能期望的那样,请使用createDirectories()(复数形式):

jshell> Files.createDirectories(Path.of("/Users/ian/once/twice/again"))
$14 ==> /Users/ian/once/twice/again

这两种变体如果成功会返回一个引用新目录的Path对象,如果失败会抛出异常。注意,createDirectories()可能(但不太可能)在创建一些目录后失败;在这种情况下,新创建的目录会留在文件系统中。

10.19 更改文件名称或其他属性

问题

你需要在磁盘上更改文件的名称或其它某些属性,例如将文件设置为只读或更改其修改时间。

解决方案

要更改名称(或位置),请使用java.nio.file.Files的静态move()方法。对于其他属性,请使用setLastModifiedTime()来更改时间戳,或者使用几种其他设置器来更改模式或权限属性。

讨论

与 Unix 命令行类似,没有单独的重命名操作;移动方法提供了将文件放置在其他位置的所有功能,无论是在不同目录中的同名文件,同一目录中的不同名称文件,还是在不同磁盘或文件系统中的不同名称文件。因此,Files.move()方法需要两个Path对象,一个引用现有文件,另一个引用新名称。然后调用Files.move()方法,依次传递这两个路径对象,首先是现有的,然后是所需的名称。这比解释更容易看到,所以让我们开始吧:

public class Rename {
    public static void main(String[] argv) throws IOException {

        // Construct the Path object. Does NOT create a file on disk!
        final Path p = Path.of("MyCoolDocument"); // The file we will rename

        // Setup for the demo: create a new "old" file
        final Path oldName = Files.exists(p) ? p : Files.createFile(p);

        // Rename the backup file to "mydoc.bak"
        // Renaming requires a Path object for the target.
        final Path newName = Path.of("mydoc.bak");
        Files.deleteIfExists(newName); // In case previous run left it there
        Path p2 = Files.move(oldName, newName);
        System.out.println(p + " renamed to " + p2);
    }
}

若要更改属性,有几种可用的方法,列在表 10-10 中。其中每个方法的返回值都是boolean类型,true表示成功。

表 10-10. 文件属性设置器

方法签名 描述
setExecutable(boolean executable) 为此文件设置所有者的执行权限的便捷方法
setExecutable(boolean executable, boolean ownerOnly) 设置此文件的所有者或所有人的执行权限
setLastModified(long time) 设置此文件或目录的最后修改时间的方法
setReadable(boolean readable) 为此文件设置所有者的读取权限的便捷方法
setReadable(boolean readable, boolean ownerOnly) 设置此文件的所有者或所有人的读取权限
setReadOnly() setReadable(false)提供便捷方式
setWritable(boolean writable) 为此文件设置所有者的写入权限的便捷方法
setWritable(boolean writable, boolean ownerOnly) 设置此文件的所有者或所有人的写入权限

对于需要两个参数的方法,第一个参数启用或禁用与方法名匹配的给定文件上的功能,而第二个参数则控制操作是仅适用于所有者还是所有人。如果文件所在的文件系统不支持多用户权限,或者操作系统不支持该功能,则忽略第二个参数。本示例中描述的所有方法都在成功时返回true,否则返回false

例如,boolean setReadable(boolean readable, boolean ownerOnly)允许您指定谁可以读取给定文件。readable参数是truefalse,取决于您是否希望其可读。ownerOnly参数尝试将可读性选择扩展到多用户操作系统上的所有用户,并且如果不适用,则将其忽略。

setLastModified() 允许您操纵文件的修改时间。通常情况下,这不是一个好的操作,但在某些类型的备份/还原程序中很有用。此方法接受一个参数,即自 Unix 时间(1970 年 1 月 1 日)开始以来的毫秒数(而不是秒数)。您可以通过调用 getLastModified() 获取文件的原始值(参见 Recipe 10.17),或者通过调用 ZonedDateTimetoInstant().getEpochSecond() 方法获取特定日期的值(参见 Recipe 6.3),并乘以 1,000 将秒转换为毫秒。

我鼓励您使用 JShell 探索这些方法的操作(参见 Recipe 1.4)。我建议您在第二个窗口中运行 ls -ldir 命令,以查看文件的影响。Example 10-11 展示了在 JShell 中探索这些方法的一些示例。

示例 10-11. 探索文件
jshell> var f = File.createTempFile("foo", "bar");
f ==> /tmp/foo9391300789087780984bar

jshell> f.createNewFile();
$4 ==> false

jshell> f.setReadOnly();
$5 ==> true

jshell> f.canRead();
$6 ==> true

jshell> f.canWrite();
$7 ==> false

jshell> f.setReadable(true);
$8 ==> true

jshell> f.canWrite();
$9 ==> false

jshell> f.setReadable(false, false);
$10 ==> true

jshell> f.canWrite();
$11 ==> false

10.20 删除文件

问题

您需要从磁盘上删除一个或多个文件。

解决方案

使用 java.nio.file.Files 对象的 delete(Path)deleteIfExists(Path) 方法。这些方法删除由 Path 参数引用的文件(当然要考虑权限)和目录(也要考虑权限和目录是否为空)。

讨论

这并不复杂。只需为要删除的文件构造一个 Path 对象,并调用静态的 Files.delete() 方法:

public class Delete {
    public static void main(String[] argv) throws IOException {

        // Construct a File object for the backup created by editing
        // this source file. The file probably already exists.
        // Some text editors create backups by putting ~ at end of filename.
        File bkup = new File("Delete.java~");
        // Now, delete it:
        bkup.delete();
    }
}

请回忆一下本章开头关于权限的警告:如果您没有权限,则可能会得到 false 的返回值或者 SecurityException。还请注意,不同平台之间存在一些差异。某些 Windows 版本允许 Java 删除只读文件,但 Unix 不允许您删除没有对其所在目录具有写权限的文件。Unix 也不允许您删除非空目录(对于后者甚至还有一个异常 DirectoryNotEmptyException)。以下是带有成功或失败报告的 Delete 版本:

public class Delete2 {

    static boolean hard = false; // True for delete, false for deleteIfExists

    public static void main(String[] argv) {
        for (String arg : argv) {
            if ("-h".equals(arg)) {
                hard = true;
                continue;
            }
            delete(arg);
        }
    }

    public static void delete(String fileName) {
        // Construct a File object for the file to be deleted.
        final Path target = Path.of(fileName);

        // Now, delete it:
        if (hard) {
            try {
                System.out.print("Using Files.delete(): ");
                Files.delete(target);
                System.err.println("** Deleted " + fileName + " **");
            } catch (IOException e) {
                System.out.println("Deleting " + fileName + " threw " + e);
            }
        } else {
            try {
                System.out.print("Using deleteIfExists(): ");
                if (Files.deleteIfExists(target)) {
                    System.out.println("** Deleted " + fileName + " **");
                } else {
                    System.out.println(
                        "Deleting " + fileName + " returned false.");
                }
            } catch (IOException e) {
                System.out.println("Deleting " + fileName + " threw " + e);
            }
        }
    }
}

-h 选项允许此程序在 delete()deleteIfExists() 之间切换;您可以通过在存在、不存在和非空的情况下运行它,使用这两种方法来查看差异。在我的 Unix 系统上,输出看起来像这样:

$ ls -ld ?
-rw-r--r--  1 ian  512   0 Dec 21 16:35 a
drwxr-xr-x  2 ian  512  64 Dec 21 16:35 b
drwxr-xr-x  3 ian  512  96 Dec 21 16:22 c
$ java -cp target/classes dir_file.Delete2 a b c d 
Using deleteIfExists(): ** Deleted a **
Using deleteIfExists(): ** Deleted b **
Using deleteIfExists(): Deleting c threw
  java.nio.file.DirectoryNotEmptyException: c
Using deleteIfExists(): Deleting d returned false.
# Here I put the files back the way they were, then run again with -h
$ java -cp target/classes dir_file.Delete2 -h a b c d
Using Files.delete(): ** Deleted a **
Using Files.delete(): ** Deleted b **
Using Files.delete(): Deleting c threw
  java.nio.file.DirectoryNotEmptyException: c
Using Files.delete(): Deleting d threw java.nio.file.NoSuchFileException: d
$ ls -l c
total 2
drwxr-xr-x  2 ian  ian  512 Oct  8 16:50 d
$ java dir_file.Delete2 c/d c
Using deleteIfExists(): ** Deleted c/d **
Using deleteIfExists(): ** Deleted c **
$ 

10.21 创建临时文件

问题

您需要创建一个具有唯一临时文件名的文件,和/或在程序完成时安排文件被删除。

解决方案

使用 java.nio.file.FilescreateTempFile()createTempDirectory() 方法。使用多种方法之一确保文件在退出时被删除。

讨论

Files类有用于创建临时文件和目录的静态方法。请注意,在这种情况下临时文件不会自动删除;它只是在该操作系统上设置为临时文件的目录中创建(例如,在 Unix 上是/tmp)。以下是创建临时文件和目录的方法:

Path createTempFile(Path dir, String prefix, String suffix, FileAttribute<?>… attrs)

在指定目录中创建一个新的空文件,使用给定的前缀和后缀字符串生成其名称

Path createTempFile(String prefix, String suffix, FileAttribute<?>… attrs)

在默认临时文件目录中创建一个空文件,使用给定的前缀和后缀生成其名称

Path createTempDirectory(Path dir, String prefix, FileAttribute<?>… attrs)

在指定目录中创建一个新目录,使用给定的前缀生成其名称

Path createTempDirectory(String prefix, FileAttribute<?>… attrs)

在默认临时文件目录中创建一个新目录,使用给定的前缀生成其名称

文件属性在侧边栏“理解 I/O 选项:StandardOpenOptions、FileAttribute、PosixFileAttribute 等”中讨论。

有多种方法可以自动安排文件被删除。其中一种方法是使用旧的java.io.File类,该类具有显式的deleteOnExit()方法。这会安排在程序退出时删除任何文件(无论如何创建)。在这里,我们安排在退出时删除一个程序的备份副本,同时创建一个临时文件并安排在退出时删除。两个文件在程序运行后都消失了:

public class TempFiles {
    public static void main(String[] argv) throws IOException {

        // 1\. Making an existing file temporary
        // Construct a File object for the backup created by editing
        // this source file. The file probably already exists.
        // My editor creates backups by putting ~ at the end of the name.
        File bkup = new File("Rename.java~");
        // Arrange to have it deleted when the program ends.
        bkup.deleteOnExit();

        // 2\. Create a new temporary file.

        // Make a file object for foo.tmp, in the default temp directory
        Path tmp = Files.createTempFile("foo", "tmp");
        // Report on the filename that it made up for us.
        System.out.println("Your temp file is " + tmp.normalize());
        // Arrange for it to be deleted at exit.
        tmp.toFile().deleteOnExit();
        // Now do something with the temporary file, without having to
        // worry about deleting it later.
        writeDataInTemp(tmp);
    }

    public static void writeDataInTemp(Path tempFile) throws IOException {
        // This version is dummy. Use your imagination.
        Files.writeString(tempFile, "This is a temp file");
    }
}

在 Unix 系统上运行时,该程序看起来像这样,证明文件已创建但在 JVM 退出时被移除:

$ java TempFiles.java
Your temp file is /tmp/foo8423321910215054689tmp
$ ls -l /tmp/foo8423321910215054689tmp
ls: /tmp/foo8423321910215054689tmp: No such file or directory
$

createTempFile()方法类似于createNewFile()(见食谱 10.18),它确实创建文件。同时要注意,如果 Java 虚拟机异常终止,删除可能不会发生。除了重命名文件或在程序退出之前关闭计算机等极端措施外,没有办法撤销deleteOnExit()的设置。

另一种安排文件在使用结束后被删除的方法是使用DELETE_ON_CLOSE选项创建文件(见表 10-5),这样在关闭文件时它将被删除。

第三种可能性的方法是使用JVM 关闭挂钩DELETE_ON_CLOSE可能是最佳选项,特别是在像大多数服务器端应用程序这样的长时间运行的应用程序中。在这些情况下,服务器可能运行数周、数月甚至数年。与此同时,所有临时文件将累积,并且 JVM 将累积一大堆延迟执行的工作列表,需要在关闭时执行。您可能会因为某些资源不足而耗尽磁盘空间或服务器内存。对于大多数这种长时间运行的应用程序,最好使用DELETE_ON_CLOSE或甚至显式的delete()操作。另一种选择是使用调度程序服务定期触发删除旧临时文件。

10.22 列出一个目录

问题

您需要列出目录中命名的文件系统条目。

解决方案

使用java.nio.file.Files的静态方法Stream<Path> list(Path dir),传递代表目录的Path

讨论

java.nio.file.Files类包含几种用于处理目录的方法。如果您只想列出目录的内容,请使用其list(Path)方法。例如,要列出当前目录中命名的文件系统实体,只需编写以下内容:

Files.list(Path.of(".")).forEach(System.out::println);

这可以仅用以下代码成为一个完整的程序。注意,在许多系统上,Path对象按照它们在目录中出现的顺序返回,这并不是按照排序的顺序。在这个简单的例子中,我们使用Stream.sorted()方法按字母顺序排序条目:

public class Ls {
    public static void main(String args[]) throws IOException {
        Files.list(Path.of("."))
            .sorted()
            .forEach(dir -> {
                System.out.println(dir);
            });
    }
}

当然,还有很多可以详细阐述的地方。您可以在页面上跨多列打印名称。或者甚至在页面下方,因为您在打印之前知道列表中的项目数。您可以省略带有前导点的文件名,就像 Unix 的ls程序一样。或者首先打印目录名;我曾经使用过一个名为lc的目录列表工具,发现它非常有用。

如果您希望递归处理目录,则不应检查每个条目以查看它是文件还是目录,并在目录上进行递归。相反,您应该使用 Recipe 10.26 中讨论的walk()walkFileTree()方法之一;这些方法会为您处理递归。还有一组Files.newDirectoryStream()方法,带有过滤回调和其他参数,返回一个DirectoryStream<Path>

10.23 获取目录根

问题

您想了解顶级目录,例如 Windows 上的C:*和D:*。

解决方案

使用静态方法FileSystems.getDefault().getRootDirectories(),它返回一个Iterable,其中包含每个根目录的Path对象。您可以打印它们或对它们进行其他操作。

讨论

操作系统在如何将多个磁盘驱动器或分区组织成文件系统方面存在差异。Microsoft Windows 采用低级设备导向的方法,其中每个磁盘驱动器都有一个名为A:*(如果你还有软盘的话!)的根目录,C:*用于第一个硬盘驱动器,其他字母用于 CD-ROM 和网络驱动器。这种方法要求你知道文件所在的物理设备。Unix、Linux 和 macOS 采用高级方法,只有一个根目录/;,不同的磁盘或分区被挂载或连接到一个统一的树中。这种方法有时要求你找出设备文件挂载的位置。也许两者都不容易,尽管 Unix 方法稍微更加一致一些。不管怎样,Java 使得你能够轻松获取根目录的列表。

静态方法FileSystems.getDefault().getRootDirectories()返回一个包含当前平台可用文件系统根目录的Iterable<Path>。下面是列出这些根目录的简短程序:

FileSystems.getDefault().getRootDirectories().forEach(System.out::println);
C:> java dir_file.DirRoots
A:\
C:\
D:\
C:>

正如你所看到的,该程序列出了我的软盘驱动器(尽管软盘驱动器空空如也,并且在我写这篇配方的时候,我把它留在了家里,我是在我的汽车中的停车场上的笔记本电脑上写的),硬盘驱动器和 CD-ROM 驱动器。

在 Unix 上只有一个根目录:

$ java dir_file.DirRoots
/
$

根列表中遗漏的一件事是所谓的UNC 文件名。UNC 文件名在某些微软平台上用于引用未在特定驱动器上本地挂载的网络可用资源。如果你的系统仍然使用这些文件名,请注意它们不会显示在listDirectoryRoots()的输出中。

10.24 使用 FileWatcher 服务来获取有关文件更改的通知

问题

当你感兴趣的文件被其他应用程序更新时,你希望得到通知。

解决方案

使用java.nio.file.FileWatchService自动获取文件更改的通知,而不必定期检查文件。

讨论

大型应用程序通常希望在文件更改时得到通知,而无需定期查看它们。例如,Java 企业 Web 服务器希望在 Servlet 和其他组件更新时得到通知。IDE 想知道文件是否被外部编辑器或构建脚本修改。许多现代操作系统有这个能力已经有一段时间了,现在它在 Java 中也是可用的。

这些是使用FileWatchService的基本步骤:

  1. 创建一个表示要监视的目录的Path对象。

  2. 通过调用例如FileSystems.getDefault().newWatchService()来获取WatchService

  3. 创建一个Kind枚举数组来监视你想要观察的内容(在我们的示例中,我们观察文件的创建或修改)。

  4. 注册WatchServiceKind数组到Path对象上。

  5. 从那时起,您会等待监视器通知您。典型的实现方式是进入while (true)循环,调用WatchServicetake()方法以获取事件,并解释事件以确定刚刚发生了什么。

示例 10-12 是一个完成这些操作的程序。此外,它启动另一个线程来执行一些文件系统操作,这样您就可以看到WatchService的操作。

示例 10-12. main/src/main/java/nio/FileWatchServiceDemo.java
public class FileWatchServiceDemo {

    final static String TEMP_DIR_PATH = "/tmp";
    static final String FILE_SEMA_FOR = "MyFileSema.for";
    final static Path SEMAPHORE_PATH = Path.of(TEMP_DIR_PATH ,FILE_SEMA_FOR);
    static volatile boolean done = false;
    final static ExecutorService threadPool = Executors.newSingleThreadExecutor();

    public static void main(String[] args) throws Throwable {
        String tempDirPath = "/tmp";
        System.out.println("Starting watcher for " + tempDirPath);
        System.out.println("Semaphore file is " + SEMAPHORE_PATH);
        Path p = Paths.get(tempDirPath);
        WatchService watcher =
            FileSystems.getDefault().newWatchService();
        Kind<?>[] watchKinds = { ENTRY_CREATE, ENTRY_MODIFY };
        p.register(watcher, watchKinds);
        threadPool.submit(new DemoService());
        while (!done) {
            WatchKey key = watcher.take();
            for (WatchEvent<?> e : key.pollEvents()) {
                System.out.println(
                    "Saw event " + e.kind() + " on " +
                    e.context());
                if (e.context().toString().equals(FILE_SEMA_FOR)) {
                    System.out.println("Semaphore found, shutting down watcher");
                    done = true;
                }
            }
            if (!key.reset()) {
                System.err.println("WatchKey failed to reset!");
            }
        }
    }

    /**
 * Nested class whose only job is to wait a while, create a file in
 * the monitored directory, and then go away.
 */
    private final static class DemoService implements Runnable {
        public void run() {
            try {
                Thread.sleep(1000);
                System.out.println("DemoService: Creating file");
                Files.deleteIfExists(SEMAPHORE_PATH); // clean up from previous run
                Files.createFile(SEMAPHORE_PATH);
                Thread.sleep(1000);
                System.out.println("DemoService: Shutting down");
            } catch (Exception e) {
                System.out.println("Caught UNEXPECTED " + e);
            }
        }
    }
}

10.25 程序:将用户数据保存到磁盘

问题

在 Java 应用程序中,您需要将用户数据保存到磁盘上。这可能是响应于 GUI 应用程序中的文件→保存,保存文本编辑器中的文件,或保存非 GUI 应用程序中的配置数据。您可能听说过(正确地),一个表现良好的应用程序绝不应该丢失数据。

解决方案

使用这个五步计划,并根据情况进行适当的变化:

  1. 创建一个临时文件;使用deleteOnExit(true)自动安排其在后续删除。

  2. 将用户数据写入此文件。在此过程中可能会抛出数据格式转换错误,但会保留用户数据文件的先前版本。

  3. 如果备份文件存在,则删除备份文件。

  4. 将用户先前的文件重命名为**.bak*。

  5. 将临时文件重命名为已保存文件。

讨论

作为开发人员,我们必须面对将文件保存到磁盘中充满风险的事实。在保存数据时可能出现许多问题,但这是大多数应用程序中最关键的部分之一。如果您丢失了一个人花了几小时输入的数据,甚至丢失了用户感到强烈关注的设置,她会憎恨您的整个应用程序。在写入过程中,磁盘可能会填满,或者在我们开始之前就已经满了。这是用户的错误,但我们必须面对它。因此,这里有一个更详细的讨论,介绍了我们应该进行的小五步舞蹈:

  1. 创建一个我们将要写入的临时文件。将此文件设置为deleteOnExit(true),这样如果在后续步骤中失败,我们就不会在磁盘上留下残余文件。因为我们稍后将重命名此文件以成为用户的真实文件,而且我们不希望在重命名过程中因为磁盘空间不足而失败,所以很重要的一点是我们必须在与用户真实文件相同的磁盘驱动器分区(驱动器号挂载点)上创建此文件;否则,重命名将悄无声息地变成复制和删除操作,这可能因为磁盘空间不足而失败。参见 Recipe 10.21 了解在退出时删除文件的方法。

  2. 将用户数据写入这个新的临时文件。如果我们在转换数据,比如从 JDBC ResultSet 获取数据或使用 XML 转换器写入对象时,可能会抛出异常。如果我们不小心,这些异常可能会导致用户的数据丢失。

  3. 如果备份文件存在,则删除备份文件。第一次执行时它不存在;之后可能会存在。无论如何都要有所准备。

  4. 将用户先前的文件重命名为**.bak*_。

  5. 将临时文件重命名为保存文件。

这可能看起来有些多余,但可以防止职业生涯的破坏。我在多个应用程序中几乎都这样做,使用各种保存文件格式。这个计划是唯一真正安全的方法,可以避免所有可能发生的问题。例如,最后一步必须是重命名而不是复制,无论考虑大小,都要避免磁盘填满的问题。因此,要正确操作,必须确保临时文件在与用户文件相同的磁盘分区(驱动器号或挂载点)上创建。

这是使用 FileSaver 的基本计划:

  • 通过调用构造函数来实例化它。

  • 调用 getWriter()getOutputStream() 方法。

  • 使用输出文件写入数据。

  • FileSaver 对象上调用 finish()

main/src/main/java/com/darwinsys/io/FileSaver.java

// package com.darwinsys.io;
public class FileSaver {

    private enum State {
        /** The state before and after use */
        AVAILABLE,
        /** The state while in use */
        INUSE
    }
    private State state;
    private final Path inputFile;
    private final Path tmpFile;
    private final Path backupFile;

    private OutputStream mOutputStream;
    private Writer mWriter;

    public FileSaver(Path inputFile) throws IOException {

        // Step 1: Create temp file in right place; must be on same disk
        // as the original file, to avoid disk-full troubles later.
        this.inputFile = inputFile;
        tmpFile = Path.of(inputFile.normalize() + ".tmp");
        Files.createFile(tmpFile);
        tmpFile.toFile().deleteOnExit();
        backupFile = Path.of(inputFile.normalize() + ".bak");
        state = State.AVAILABLE;
    }

    /**
 * Return a reference to the contained File object, to
 * promote reuse (File objects are immutable so this
 * is at least moderately safe). Typical use would be:
 * <pre>
 * if (fileSaver == null ||
 *   !(fileSaver.getFile().equals(file))) {
 *        fileSaver = new FileSaver(file);
 * }
 * </pre>
 * @return the File object for the file to be saved
 */
    public Path getFile() {
        return inputFile;
    }

    /** Return an output file that the client should use to
 * write the client's data to.
 * @return An OutputStream, which should be wrapped in a
 *     buffered OutputStream to ensure reasonable performance.
 * @throws IOException if the temporary file cannot be written
 */
    public OutputStream getOutputStream() throws IOException {

        if (state != State.AVAILABLE) {
            throw new IllegalStateException("FileSaver not opened");
        }
        mOutputStream = Files.newOutputStream(tmpFile);
        state = State.INUSE;
        return mOutputStream;
    }

    /** Return an output file that the client should use to
 * write the client's data to.
 * @return A BufferedWriter to write on the new file.
 * @throws IOException if the temporary file cannot be written
 */
    public Writer getWriter() throws IOException {

        if (state != State.AVAILABLE) {
            throw new IllegalStateException("FileSaver not opened");
        }
        mWriter = Files.newBufferedWriter(tmpFile);
        state = State.INUSE;
        return mWriter;
    }

    /** Close the output file and rename the temp file to the original name.
 * @throws IOException If anything goes wrong
 */
    public void finish() throws IOException {

        if (state != State.INUSE) {
            throw new IllegalStateException("FileSaver not in use");
        }

        // Ensure both are closed before we try to rename.
        if (mOutputStream != null) {
            mOutputStream.close();
        }
        if (mWriter != null) {
            mWriter.close();
        }

        // Delete the previous backup file if it exists.
        Files.deleteIfExists(backupFile);

        // Rename the user's previous file to itsName.bak,
        // UNLESS this is a new file.
        if (Files.exists(inputFile) &&
            Files.move(inputFile, backupFile) == null) {
            throw new IOException(
                "Could not rename file to backup file " + backupFile);
        }

        // Rename the temporary file to the save file.
        if (Files.move(tmpFile, inputFile) == null) {
            throw new IOException("Could not rename temp file to save file");
        }
        state = State.AVAILABLE;
    }
}

致谢

这个程序中的代码是我根据在各种应用程序中的经验自己编写的。我被 Brendon McLean 在现已废弃的 Java 应用程序框架 JSR-296 的邮件列表上的一篇帖子激发,以这种方式打包并写出它。

10.26 程序:Find —— 遍历文件树

示例 10-13 中的程序实现了 Windows 的 查找文件 对话框或 Unix 的 find 命令的子集。它具有构建更完整版本所需的大部分结构。它从标准 Unix 的 find 命令接受以下选项(带有限制):

-n *name*

要查找的名称。如果从 shell 引用,则可以包含 shell 通配符。

-s *size*

要查找的文件大小。可以以加号表示大于或减号表示小于。

-a, -o

And or or, but only one of these, between a -n and a -s.

Files 类有四种方法来遍历文件树。其中两种返回延迟填充的 Stream<Path>,另外两种为每个找到的文件或目录调用回调 FileVisitor。我的 find 实现使用第一种;这四种方法在 表 10-11 中有概述。

表 10-11. 文件树遍历方法

返回 签名
Stream<Path> walk(Path start, FileVisitOption… options)
Stream<Path> walk(Path start, int maxDepth, FileVisitOption… options)
Path walkFileTree(Path start, FileVisitor<? super Path> visitor)
Path walkFileTree(Path start, Set options, int maxDepth, FileVisitor<? super Path> visitor)

使用 walk() 方法就像这样简单:

Files.walk(startingPath).forEach(path -> {
	// Do something with Path path; might be file, directory or other...
}

那段代码位于 示例 10-13 的 startWalkingAt() 方法的开头附近。

示例 10-13. main/src/main/java/dir_file/Find.java
/**
 * Find - find files by name, size, or other criteria. Non-GUI version.
 */
public class Find {

    public enum Conjunction { AND, OR };

    private static Logger logger = Logger.getLogger(Find.class.getSimpleName());
    static boolean started;

    /** Main program
 * @throws IOException If the Files.walkTree does so
 */
    public static void main(String[] args) throws IOException {
        Find finder = new Find();

        if (args.length == 0) {
            finder.startWalkingAt(".");
        } else {
            for (int i = 0; i < args.length; i++) {
                if (args[i].charAt(0) == '-') {
                    switch(args[i].substring(1)) {
                    case "name":
                        finder.filter.setNameFilter(args[++i]);
                        continue;
                    case "size":
                        finder.filter.setSizeFilter(args[++i]);
                        continue;
//                    Not implemented by back-end yet
//                    case "a":
//                        finder.filter.addConjunction(Conjunction.AND);
//                        continue;
//                    case "o":
//                        finder.filter.addConjunction(Conjunction.OR);
//                        continue;
                    default: throw new IllegalArgumentException(
                        "Unknown argument " + args[i]);
                    }
                }
                finder.startWalkingAt(args[i]);
            }
            if (!started) {
                finder.startWalkingAt(".");
            }
        }
    }

    protected FindFilter filter = new FindFilter();

    public static void usage() {
        System.err.println(
            "Usage: Find [-n namefilter][-s sizefilter][dir...]");
        System.exit(1);
    }

    /** doName - handle one filesystem object by name */
    private void startWalkingAt(String s) throws IOException {
        logger.info("doName(" + s + ")");
        started = true;
        Path f = Path.of(s);
        if (!Files.exists(f)) {
            System.out.println(s + " does not exist");
            return;
        }
        Files.walk(f).forEach(fp -> {
            try {
                if (Files.isRegularFile(fp))
                    doFile(fp);
                else if (Files.isDirectory(fp)) {
                    doDir(fp);
                } else {
                    System.err.println("Unknown type: " + s);
                }
            } catch (IOException e) {
                throw new RuntimeException("IO Exception: " + e);
            }
        });
    }

    /** doFile - process one regular file.
 * @throws IOException */
    private void doFile(Path f) throws IOException {
        if (filter.accept(f)) {
            System.out.println("f " + f);
        }
    }

    /** doDir - process a directory */
    private void doDir(Path d) {
        System.out.println("d " + d.normalize());
    }
}

示例 10-14 展示了一个名为 FindFilter 的类,是 Find 的后端实现。

示例 10-14. main/src/main/java/dir_file/FindFilter.java
/** Class to encapsulate the filtration for Find.
 * For now just set*Filter() methods. Really needs to be a real
 * data structure (maybe LinkedList<FilterOp> or a Tree) for complex
 * requests like:
 *    -n "*.html" -a \( -size < 0 -o mtime < 5 \).
 */
public class FindFilter {
    private enum SizeMode {GT, EQ, LT};
    SizeMode sizeMode;
    Find.Conjunction conj;
    long size;
    String name;
    Pattern nameRE;
    boolean debug = false;

    void setSizeFilter(String sizeFilter) {
        System.out.println("FindFilter.setSizeFilter()");
        sizeMode = SizeMode.EQ;
        char c = sizeFilter.charAt(0);
        if (c == '+') {
            sizeMode = SizeMode.GT;
            sizeFilter = sizeFilter.substring(1);
        } else {
            if (c == '-') {
                sizeMode = SizeMode.LT;
                sizeFilter = sizeFilter.substring(1);
            }
        }
        size = Long.parseLong(sizeFilter);
    }

    /** Add a conjunction */
    public void addConjunction(Find.Conjunction conj) {
        System.out.println("FindFilter.addConjunction()");
        if (this.conj != null) {
            throw new IllegalArgumentException(
                "Only one conjucntion allowed in this version");
        }
        this.conj = conj;
    }

    /** Convert the given shell wildcard pattern into internal form (an RE) */
    void setNameFilter(String nameToFilter) {
        nameRE = makeNameFilter(nameToFilter);
    }

    Pattern makeNameFilter(String name) {
        StringBuilder sb = new StringBuilder('^');
        for (char c : name.toCharArray()) {
            switch(c) {
                case '.':    sb.append("\\."); break;
                case '*':    sb.append(".*"); break;
                case '?':    sb.append('.'); break;
                // Some chars are special to RE and have to be escaped
                case '[':    sb.append("\\["); break;
                case ']':    sb.append("\\]"); break;
                case '(':    sb.append("\\("); break;
                case ')':    sb.append("\\)"); break;
                default:    sb.append(c); break;
            }
        }
        sb.append('$');
        if (debug) {
            System.out.println("RE=\"" + sb + "\".");
        }
        // Should catch PatternException and rethrow for better diagnostics
        return Pattern.compile(sb.toString());
    }

    /** Do the filtering. For now, only filter on name, size or name+size */
    public boolean accept(Path p) throws IOException {
        if (debug) {
            System.out.println("FindFilter.accept(" + p + ")");
        }

        if (nameRE != null) {
            return nameRE.matcher(p.getFileName().toString()).matches();
        }

        // size handling
        if (sizeMode != null) {
            long sz = Files.size(p);
            switch (sizeMode) {
            case EQ:
                return (sz == size);
            case GT:
                return (sz > size);
            case LT:
                return (sz < size);
            }
        }

        // Catchall
        return false;
    }

    public String getName() {
        return name;
    }
}

¹ 名称选择不佳:它是在 Java SE 1.4 中新增的。但比 InputStream/OutputStream(Java 1.0)和 Readers/Writers(1.1)更新。

² 不完全正确;在 java.nio.FileChannel 类中是有的,但我们没有涵盖它。

³ 《少年派的奇幻漂流》中的中心人物应该是根据 Wikipedia 中的信息于 1956 年出生。

⁴ 如果这是一个维护项目中的代码,我可能会将这两个计算器中的一些常见代码因子提取出来,以及 Recipe 5.12 中的代码,并使用接口更好地划分代码。然而,这会减弱自包含示例的简洁性。

⁵ 不支持向现有存档添加文件,因此确保一次性将所有文件放入或准备好从头开始重新创建存档。

第十一章:数据科学与 R

数据科学是一个相对较新的学科,最初因 O’Reilly 的 Mike Loukides 的这篇文章 而引起了许多人的注意。虽然在这个领域有许多定义,但 Loukides 将他对该领域的详细观察和参与归结为这个定义:

数据应用从数据本身获取其价值,并随之生成更多数据。它不仅仅是一个带有数据的应用程序;它是一个数据产品。数据科学使得数据产品的创建成为可能。

用于数据科学软件的主要开源生态系统之一位于 Apache,包括 Hadoop(包括 HDFS 分布式文件系统,Hadoop Map/Reduce,¹ Ozone 对象存储和 Yarn 调度程序)、Cassandra 分布式数据库Spark 计算引擎。请阅读 Hadoop 页面的“模块和相关工具”部分以获取当前列表。

这里有趣的是,许多数据科学家视为理所当然的大部分基础设施都是用 Java 和 Scala(一种 JVM 语言)编写的。其余大部分则是用 Python 编写的,这是一种与 Java 互补的语言。

数据科学问题可能涉及大量的设置,所以我们只会从传统数据科学中给出一个使用 Spark 框架的例子。Spark 是用 Scala 编写的,因此可以直接被 Java 代码使用。

在本章的其余部分,我将专注于一种称为 R 的语言,它在统计学和数据科学中被广泛使用(好吧,在许多其他科学领域也是如此;你在同行评审的期刊文章中看到的许多图表都是用 R 准备的)。R 被广泛使用,了解它是很有用的。它的主要实现不是用 Java 编写的,而是用 C、Fortran 和 R 本身的混合语言。但是 R 可以在 Java 中使用,Java 也可以在 R 中使用。我将讨论几种 R 的实现方式以及如何选择一种,然后展示如何从 R 中使用 Java,从 Java 中使用 R,以及在 Web 应用程序中使用 R 的技术。

11.1 使用 Java 进行机器学习

问题

你想要使用 Java 进行机器学习和数据科学,但每个人都告诉你要使用 Python。

解决方案

使用众多免费下载的强大 Java 工具包之一。

讨论

有时候人们说机器学习(ML)和深度学习必须用 C++ 来提高效率,或者用 Python 来利用广泛的软件可用性。尽管这些语言各有其优势和支持者,但确实可以使用 Java 来实现这些目的。然而,设置这些软件包并展示一个简短的演示比适合本书典型的配方格式要长。

随着行业巨头亚马逊发布基于 Java 的 Deep Java Learning (DJL) 库,以及许多其他优秀的库(其中不少支持 CUDA 以加速 GPU 计算)(参见 Table 11-1),没有理由不使用 Java 进行机器学习。除了 DJL 外,我尽量列出那些仍在维护且在用户中口碑不错的包。

Table 11-1. 一些 Java 机器学习包

Library name 描述 信息网址 源码网址
ADAMS 用于构建/维护数据驱动反应式工作流程的工作流引擎;与业务流程集成 https://adams.cms.waikato.ac.nz/ https://github.com/waikato-datamining/adams-base
Deep Java Library 亚马逊的机器学习库 https://djl.ai https://github.com/awslabs/djl
Deeplearning4j DL4J,Eclipse 的分布式深度学习库;与 Hadoop 和 Apache Spark 集成 https://deeplearning4j.org/ https://github.com/eclipse/deeplearning4j
ELKI 数据挖掘工具包 https://elki-project.github.io/ https://github.com/elki-project/elki
Mallet 用于文本处理的机器学习库 mallet.cs.umass.edu https://github.com/mimno/Mallet.git
Weka 数据挖掘的机器学习算法;提供数据准备、分类、回归、聚类、关联规则挖掘和可视化工具 https://www.cs.waikato.ac.nz/ml/weka/index.html https://svn.cms.waikato.ac.nz/svn/weka/trunk/weka

另请参阅

书籍 Data Mining: Practical Machine Learning and Techniques 由 Ian Witten 等人(Morgan Kaufmann 出版)编写,他们也是 Weka 背后团队的成员。

还可以参考 Eugen Parschiv 的 Java AI 软件包列表

11.2 在 Apache Spark 中使用数据

问题

您希望使用 Spark 处理数据。

解决方案

创建一个 SparkSession,使用其 read() 函数读取 DataSet,应用操作并总结结果。

讨论

Spark 是一个非常庞大的主题!已经有整本书专门讲述了它。引用 Databricks,这个团队是 Spark 最初的开发者之一:²

Apache Spark™ 在过去几年中取得了巨大的增长,成为今天企业中的事实标准数据处理和 AI 引擎,这归功于其速度、易用性和复杂的分析功能。Spark 通过简化跨多个来源的大规模数据准备,为数据工程和数据科学工作负载提供一致的 API 集,以及与流行的 AI 框架和库(如 TensorFlow、PyTorch、R 和 SciKit-Learn)的无缝集成,统一了数据和人工智能。

我无法在本书中传达整个主题内容。然而,Spark 擅长处理大量数据,比如在示例 11-1 中,我们读取了一个 Apache 格式的日志文件,并找到(和计数)具有 200、404 和 500 响应的行。

示例 11-1. spark/src/main/java/sparkdemo/LogReader.java
import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.Dataset;
import org.apache.spark.api.java.function.FilterFunction;

/**
 * Read an Apache Logfile and summarize it.
 */
public class LogReader {

    public static void main(String[] args) {

        final String logFile = "/var/wildfly/standalone/log/access_log.log";    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        SparkSession spark =
            SparkSession.builder().appName("Log Analyzer").getOrCreate();       ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        Dataset<String> logData = spark.read().textFile(logFile).cache();       ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)

        long good = logData.filter(                                             ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        new FilterFunction<>() {public boolean call(String s) {
                    return s.contains("200");
                }
            }).count();

        long bad = logData.filter(new FilterFunction<>() {
                public boolean call(String s) {
                    return s.contains("404");
                }
            }).count();

        long ugly = logData.filter(new FilterFunction<>() {
                public boolean call(String s) {
                    return s.contains("500");
                }
            }).count();

        System.out.printf(                                                      ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
            "Successful transfers %d, 404 tries %d, 500 errors %d\n",
            good, bad, ugly);

        spark.stop();
    }
}

1

设置日志文件的文件名。可能应该从args中获取。

2

启动 Spark SparkSession对象——运行时环境。

3

告诉 Spark 读取日志文件并将其保留在内存中(缓存)。

4

定义 200、404 和 500 错误的过滤器。它们应该能够使用 lambda 表达式来使代码更简洁,但 Java 和 Scala 版本的FilterFunction之间存在歧义。

5

打印结果。

要使其编译通过,您需要将以下内容添加到 Maven 的 POM 文件中:

<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-sql_2.12</artifactId>
    <version>2.4.4</version>
    <scope>provided</scope>
</dependency>

然后你应该能够执行mvn package命令来生成一个打包好的 JAR 文件。

使用provided范围的原因是因为我们还将从Spark 下载页面下载 Apache Spark 运行时包以运行应用程序。解压分发包并将SPARK_HOME环境设置为其根目录:

SPARK_HOME=~/spark-3.0.0-bin-hadoop3.2/

然后你可以使用我在源代码下载中提供的run脚本(javasrc/spark)。

Spark 的设计面向比这个简单示例更大规模的计算,因此其庞大的输出简直淹没了我简单示例程序的输出。尽管如此,对于一个大约有 42,000 行的文件,我确实得到了这个结果,埋藏在日志记录中:

Successful transfers 32555, 404 tries 6539, 500 errors 183

如前所述,Spark 是一个庞大的主题,但对大多数数据科学家来说是一个必不可少的工具。你可以使用 Java(显然),或者 Scala 来编写 Spark 程序。Scala 是一种促进函数式编程的 JVM 语言(参见此 Scala 教程供 Java 开发人员使用),以及 Python 和可能其他语言。你可以在https://spark.apache.org或者在线的众多书籍、视频和教程中了解更多。

11.3 使用 R 进行交互

问题

你对 R 一无所知,但你想要了解它。

解决方案

R 已经存在多年,其前身 S 则存在了十年之久。有许多书籍和在线资源致力于这种语言。官方主页位于https://www.r-project.org。还有许多在线教程;R 项目提供了一个。R 本身可以在大多数系统的软件包管理器中找到,并且可以从官方下载站点下载。这些 URL 中的名称 CRAN 代表 Comprehensive R Archive Network,类似于 TeX 的 CTAN 和 Perl 语言的 CPAN。

在这个例子中,我们将从 Java 程序中写入一些数据,然后使用 R 进行交互式分析和绘图。

讨论

这只是一个使用 R 进行交互式操作简介。可以说,R 是一个非常有价值的交互式环境,用于探索数据。以下是一些简单的计算,展示了该语言的特色:一个健谈的启动(如此之长,我不得不截断了一部分),简单算术运算,如果未保存则自动打印结果,当你犯错误时会有相当不错的错误提示,以及对向量的算术运算。你可能会发现与 Java 的 JShell(见食谱 1.4)有些相似之处;它们都是 REPL(读取-求值-打印 循环)接口。R 添加了在退出程序时保存你的交互会话(工作空间)的功能,因此下次启动 R 时会恢复所有的数据和函数定义。展示 R 语法的简单交互会话可能如下所示:

$ R

R version 3.6.0 (2019-04-26) -- "Planting of a Tree"
Copyright (C) 2019 The R Foundation for Statistical Computing
Platform: x86_64-apple-darwin15.6.0 (64-bit)

R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.

...

> 2 + 2
[1] 4
> x = 2 + 2
> x
[1] 4
> r = 10 20 30 40 50
Error: unexpected numeric constant in "r = 10 20"
> r = c(10,20,30,45,55,67)
> r
[1] 10 20 30 45 55 67
> r+3
[1] 13 23 33 48 58 70
> r / 3
[1]  3.333333  6.666667 10.000000 15.000000 18.333333 22.333333
>quit()
Save workspace image? [y/n/c]: n
$

R 纯粹主义者通常会在分配时使用赋值箭头 ← 替代 = 符号。如果你喜欢这样,可以去尝试。

这个简短的会话只是浅尝辄止:R 提供了数百个内置函数、示例数据集、一千多个附加包、内置帮助等等。对于交互式数据探索,R 确实是首选。

有些人更喜欢使用 R 的图形用户界面。RStudio 是最广泛使用的 GUI 前端。

现在我们想要从 Java 中写入一些数据,并在 R 中进行处理(我们将在本章后续的食谱中一起使用 Java 和 R)。在食谱 5.9 中,我们讨论了 java.util.Random 类及其 nextDouble()nextGaussian() 方法。nextDouble() 和相关方法试图提供在 0 到 1.0 之间的平均分布,其中每个值被选择的概率相等。高斯或正态分布是从负无穷到正无穷的钟形曲线,大多数值聚集在零(0.0)附近。我们将使用 R 的直方图和图形函数来视觉化地检查它们的效果:

Random r = new Random();
for (int i = 0; i < 10_000; i++) {
    System.out.println("A normal random double is " + r.nextDouble());
    System.out.println("A gaussian random double is " + r.nextGaussian());

为了说明不同的分布,我使用 nextRandom()nextGaussian() 生成了 10,000 个数字。代码在 Random4.java 中(此处未显示),是前面示例代码和仅将数字打印到两个文件中的代码的组合。然后我使用 R 绘制了直方图;生成图形的 R 脚本在 javasrc 下的 src/main/resources 中,但其核心显示在示例 11-2 中。结果显示在图 11-1 中。

示例 11-2. 生成直方图的 R 命令
png("randomness.png")
us <- read.table("normal.txt")[[1]]
ns <- read.table("gaussian.txt")[[1]]

layout(t(c(1,2)), respect=TRUE)

hist(us, main = "Using nextRandom()", nclass = 10,
       xlab = NULL, col = "lightgray", las = 1, font.lab = 3)

hist(ns, main = "Using nextGaussian()", nclass = 16,
       xlab = NULL, col = "lightgray", las = 1, font.lab = 3)
dev.off()

png() 调用告诉 R 使用哪个图形设备。其他包括 X11()Postscript()read.table() 从文本文件中读取数据到表格中;[1] 给出了我们只需要的数据列,忽略了一些元数据。layout() 调用表示我们想要两个并排显示的图形对象。每个 hist() 调用绘制两个直方图中的一个。而 dev.off() 关闭输出并刷新任何写入缓冲区到 PNG 文件。结果显示在图 11-1 中。

jcb4 1101

图 11-1. 平面(左)和高斯(右)分布

11.4 比较/选择 R 实现

问题

你不确定要使用哪个 R 的实现。

解决方案

查看原始的 R、Renjin 和 FastR。

讨论

R 的原始版本是 S,这是一个由约翰·钱伯斯等人于 1976 年在 AT&T 贝尔实验室开发的交互式编程环境。我在支持多伦多大学统计系时遇到了 S,还在为一个名为 Sun Expert 的很久以前的杂志审查了它的商业实现 SPlus。AT&T 只向大学和无法进一步分发源代码的商业许可证持有人提供 S 源代码。奥克兰大学的两位开发人员 Ross Ihaka 和 Robert Gentleman 从 1995 年开始开发了 S 的克隆,并将其命名为 R,以代表他们自己的首字母,同时也是对 S 名称的一种玩笑。(这方面有先例:在 Unix/Linux 上流行的 awk 语言是以其设计者 Aho、Weinberger 和 Kernighan 的首字母命名的)。R 发展迅速,因为它与 S 非常兼容,并且更容易获取。这个原始 R 的实现由R Foundation for Statistical Computing积极管理,该基金会还管理综合 R 存档网络

Renjin 是一个在 Java 中相当完整的 R 实现。该项目通过他们自己的 Maven 仓库提供构建的 JAR 文件。

FastR 是另一个 Java 实现,在更快的 GraalVM 中运行,并支持从几乎任何其他编程语言直接调用 JVM 代码。FastR 的技术负责人在这篇博文中描述了该实现。

除了这些实现,R 的流行还促使开发了许多访问库,用于从许多流行的编程语言调用 R。Rserve 是一个 TCP/IP 网络访问模式,为其存在 Java 封装。

11.5 在 Java 应用程序中使用 R:Renjin

问题

您希望通过 Renjin 从 Java 应用程序中访问 R。

解决方案

将 Renjin 添加到您的 Maven 或 Gradle 构建中,并通过 Recipe 18.3 中描述的脚本引擎机制进行调用。

讨论

Renjin 是一个纯 Java 实现的开源 R 重现,提供脚本引擎接口。将以下依赖项添加到您的构建工具中:

org.renjin:renjin-script-engine:3.5-beta76

当然,阅读本文时可能已经有更新版本的 Renjin;除非有特殊原因,应使用最新版本。

注意,您还需要一个 <repository> 条目,因为维护者将其构件放在 nexus.betadriven.com 而不是通常的 Maven Central。这是我使用的内容(从 https://www.renjin.org/downloads.html 获取):

<repositories>
    <repository>
        <id>bedatadriven</id>
        <name>bedatadriven public repo</name>
        <url>https://nexus.bedatadriven.com/content/groups/public/</url>
    </repository>
</repositories>

一旦完成这些步骤,您应该能够通过脚本引擎框架访问 Renjin,就像 示例 11-3 中描述的那样。

示例 11-3. main/src/main/java/otherlang/RenjinScripting.java
    /**
 * Demonstrate interacting with the "R" implementation called "Renjin"
 */
    public static void main(String[] args) throws ScriptException {
        ScriptEngineManager manager = new ScriptEngineManager();
        ScriptEngine engine = manager.getEngineByName("Renjin");
        engine.put("a", 42);
        Object ret = engine.eval("b <- 2; a*b");
        System.out.println(ret);
    }

因为 R 将所有数字视为浮点数,类似于许多解释器,打印的值是 84.0

还可以让 Renjin 调用一个脚本文件;示例 11-4 调用与 Recipe 11.3 中使用的相同脚本来生成和绘制一批伪随机数。

示例 11-4. 使用脚本文件的 Renjin
    private static final String R_SCRIPT_FILE = "/randomnesshistograms.r";
    private static final int N = 10000;

    public static void main(String[] argv) throws Exception {
        // java.util.Random methods are non-static, do need to construct
        Random r = new Random();
        double[] us = new double[N], ns = new double[N];
        for (int i=0; i<N; i++) {
            us[i] = r.nextDouble();
            ns[i] =r.nextGaussian();
        }
        try (InputStream is =
            Random5.class.getResourceAsStream(R_SCRIPT_FILE)) {
            if (is == null) {
                throw new IllegalStateException("Can't open R file ");
            }
            ScriptEngineManager manager = new ScriptEngineManager();
            ScriptEngine engine = manager.getEngineByName("Renjin");
            engine.put("us", us);
            engine.put("ns", ns);
            engine.eval(FileIO.readerToString(new InputStreamReader(is)));
        }
    }

如果您从 https://renjin.org/downloads.html 下载一个包含所有依赖项的 JAR 文件,Renjin 也可以作为一个独立的 R 实现来使用。

11.6 在 R 会话中使用 Java

问题

您正在使用 R 计算的过程中,意识到有一个 Java 库可以完成下一步操作。或者出于其他原因,需要在 R 会话中调用 Java 代码。

解决方案

安装 rJava,调用 .jinit(),然后使用 J() 加载类或调用方法。

讨论

这里是交互式 R 会话的一部分,在这部分中我们安装 rJava,通过调用 .jinit() 进行初始化,并调用 java.time.LocalDate.now() 获取当前日期:

> install.packages('rJava')                            ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
trying URL 'http://.../rJava_0.9-11.tgz' Content type 'application/x-gzip' length 745354 bytes (727 KB)
==================================================
downloaded 727 KB

The downloaded binary packages are in
    /tmp//Rtmp6XYZ9t/downloaded_packages > library('rJava')                                    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
> .jinit()
> J('java.time.LocalDate', 'now')                    ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
[1] "Java-Object{2019-11-22}"
> d=J('java.time.LocalDate', 'now')$toString()        ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
> d
[1] "2019-11-22"

1

安装 rJava 包;只需执行一次。

2

加载 rJava,并使用 .jinit() 初始化;每个 R 会话都需要这两步。

3

J 函数接受一个完整类名作为参数。如果只提供该参数,则返回一个类描述符(例如 java.lang.Class 对象)。如果提供多于一个参数,则第二个参数是静态方法名,后续的参数将传递给该方法。

4

返回的对象可以使用标准的 R $ 符号调用 Java 方法;这里调用 toString() 方法以返回字符串而不是 LocalDate 对象。

.jcall 函数使您可以更好地控制调用方法和返回类型:

> d=J('java.time.LocalDate', 'now')                    ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
> .jcall(d, "I", 'getYear')                            ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
[1] 2019
>
> .jcall("java/lang/System","S","getProperty","user.dir") ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
[1] "/home/ian"
> c=J('java/lang/System')                            ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
> .jcall(c, "S", 'getProperty', 'user.dir')
[1] "/home/ian"
>

1

在 R 变量 d 中调用 Java LocalDate.now() 方法并保存结果。

2

LocalDate 对象上调用 Java getYear() 方法;“I”告诉 jcall 期望一个整数结果。

3

调用 System.getProperty("user.dir") 并打印结果;“S”告诉 .jcall 期望返回一个字符串。

4

如果您将要多次使用一个类,请保存 Class 对象,并将其作为 .jcall() 的第一个参数传递。

这里有更多的功能;请参考文档developer.com 的文章

11.7 使用 FastR,GraalVM 实现的 R

问题

您使用 R 语言,但感觉需要更快的速度。

解决方案

使用 FastR,Oracle 的 GraalVM 重新实现的 R 语言。

讨论

假设您已经按照第 1.2 节的描述安装了 GraalVM,您可以直接输入以下命令:

$ gu install R
Downloading: Component catalog from www.graalvm.org
Processing component archive: FastR
Downloading: Component R: FastR  from github.com
Installing new component: FastR (org.graalvm.R, version 19.2.0.1)
NOTES:
---------------
The user specific library directory was not created automatically.
You can either create the directory manually or edit file
/Library/Java/JavaVirtualMachines/graalvm-ce-19.2.0.1/Contents/
  Home/jre/languages/R/etc/Renviron
to change it to any desired location. Without user specific library
directory, users will need write permission for the GraalVM home
directory in order to install R packages.
...
[more install notes]

如果您已将 PATH 设置为在其他目录之前使用 GraalVM,那么命令 R 现在将给出 GraalVM 版本的 R。要访问标准的 R,您将需要设置您的 PATH 或者给出 R 安装的完整路径。在所有 Unix 和类 Unix 系统上,命令 which R 将显示您的 PATH 上的所有 R 命令:

$ which R
/Library/Java/JavaVirtualMachines/graalvm-ce-19.2.0.1/Contents/Home/bin/R
/usr/local/bin/R

让我们来运行它:

$ R
R version 3.5.1 (FastR)
Copyright (c) 2013-19, Oracle and/or its affiliates
Copyright (c) 1995-2018, The R Core Team
Copyright (c) 2018 The R Foundation for Statistical Computing
Copyright (c) 2012-4 Purdue University
Copyright (c) 1997-2002, Makoto Matsumoto and Takuji Nishimura
All rights reserved.

FastR is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.

R is a collaborative project with many contributors.
Type 'contributors()' for more information.

Type 'q()' to quit R.
[Previously saved workspace restored]

> 2 + 2
[1] 4
> ^D
Save workspace image? [y/n/c]: n
$

从那时起,您应该能够做几乎任何在标准 R 中做的事情,因为此 R 的源代码大部分来自 R 基金会的源代码。

11.8 在 Web 应用程序中使用 R

问题

您希望在 Web 服务器上的网页中显示 R 的数据和图形。

解决方案

有几种方法可以实现这个效果:

  • 准备数据,生成图形,就像我们在第 11.3 节的示例中所做的那样,然后将它们都整合到静态网页中。

  • 使用多种R 的附加 Web 框架,如 shinyRook

  • 在 Servlet、JSF、Spring Bean 或其他 Web 层组件中调用 R 的 JVM 实现。

讨论

第一种方法很简单,这里不需要讨论。

对于第二种方法,我实际上会使用 timevis,它反过来使用 shiny。这并未内置到 R 库中,因此我们首先需要使用 R 的 install.packages() 安装它:

$ R
> install.packages('timevis')
> quit()
$

这可能需要一些时间,因为它会下载并构建多个依赖项。

为了这个演示,我有一个包含一些关于中世纪文学基本信息的小数据集,我使用shiny加载和显示:

# Draw the timeline for the epics.

epics = read.table("epics.txt", header=TRUE, fill=TRUE)

# epics

library("timevis")

timevis(epics)

运行时,这会创建一个包含 HTML 和 JavaScript 的临时文件,以允许对数据进行交互式探索。该库还会在浏览器中打开此文件,显示在图 11-2 中。用户可以通过展开或收缩时间线并横向滚动来探索数据。

jcb4 1102

图 11-2. TimeVis(shiny)的操作

当存在两个框(Cid,Sagas)时,第一个是生活或故事发生的时间,第二个是它们被书写的时间。

要将其暴露在公共网络上,请复制文件(完整路径显示在浏览器标题栏中)和lib文件夹放入同一目录,并将该目录服务于 Web 服务器。或者直接使用文件→另存为→完整网页,在浏览器中执行。无论哪种方式,您都必须在 R 会话运行时执行此操作,因为会话结束时会删除临时文件。或者,如果您熟悉shiny框架,可以将timevis可视化插入到shiny应用程序中。

¹ Map/Reduce是由 Google 开发的处理大数据问题的著名算法。未指定数量的生成器处理map数据,如网页上的单词或页面的 URL,单个(通常)reduce 进程将这些映射减少为可管理的形式,例如包含给定单词的所有页面的列表。早期,数据科学试图通过 Map/Reduce 做所有事情;现在,风向标已经回到使用像 Spark 这样的计算引擎。

² DataBricks 在其网站上提供了几本关于 Spark 的免费电子书;它还提供商业 Spark 附加组件。

第十二章:网络客户端

12.0 引言

Java 可用于编写多种类型的网络程序。在传统基于套接字的代码中,程序员负责构建客户端和服务器之间的交互;TCP 套接字代码 简单地确保您发送的任何数据都能到达另一端。在更高级别的类型(如 HTTP、RMI、CORBA 和 EJB)中,软件接管了更多的控制权。套接字通常用于连接传统服务器;如果您从头开始编写新应用程序,最好使用更高级别的服务。

将套接字与电话系统进行比较可能会有所帮助。电话最初用于模拟语音通信,这种通信结构相当不结构化。随后它开始用于一些分层应用程序;第一个广泛流行的分层应用程序是传真传输,即传真。如果没有广泛的语音电话服务,传真会处于何种地位呢?历史上第二个极为流行的分层应用程序是拨号 TCP/IP。这与 Web 共同存在,成为大众市场服务的流行方式。如果没有广泛部署的语音线路,拨号 IP 会如何呢?如果没有拨号 IP,互联网会处于何种地位呢?现在传真和拨号几乎都已经消失,但它们为您智能手机的联网功能铺平了道路,这正是使其有用(甚至作为时间的耗费者)的原因。

套接字也是分层的。Web、RMI、JDBC、CORBA 和 EJB 都是基于套接字的。HTTP 现在是最常用的协议,当您只想从点 b 获取数据到点 a 时,通常应该使用它。

自从 Java 在 1995 年 5 月发布初版(最初是 HotJava 浏览器的一个附带产品)以来,Java 作为一种用于构建网络应用程序的编程语言就变得非常流行。如果你曾经在 C 中构建过网络应用程序,你就会明白其中的原因。首先,C 程序员必须关注他们所在的平台。Unix 使用同步套接字,其读写操作类似于普通磁盘文件,而 Microsoft 操作系统使用异步套接字,其使用回调来通知读写操作何时完成。Java 则模糊了这种区别。此外,在 C 中设置套接字所需的代码量令人望而却步。只是出于乐趣,示例 12-1 展示了设置客户端套接字的典型 C 代码。请记住,这只是 Unix 的一部分。而且只是建立和关闭连接的部分。要在 Windows 上移植,还需要一些额外的条件代码(使用 C 的 #ifdef 机制)。C 的 #include 机制要求必须精确包含正确的文件,并且某些文件必须按特定顺序列出(Java 的 import 机制则更加灵活)。

示例 12-1. main/src/main/java/network/Connect.c(C 客户端设置)
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>

int
main(int argc, char *argv[])
{
    char* server_name = "localhost";
    struct hostent *host_info;
    int sock;
    struct sockaddr_in server;

    /* Look up the remote host's IP address */
    host_info = gethostbyname(server_name);
    if (host_info == NULL) {
        fprintf(stderr, "%s: unknown host: %s\n", argv[0], server_name);
        exit(1);
    }

    /* Create the socket */
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        perror("creating client socket");
        exit(2);
    }

    /* Set up the server's socket address */
    server.sin_family = AF_INET;
    memcpy((char *)&server.sin_addr, host_info->h_addr,
                     host_info->h_length);
    server.sin_port = htons(80);

    /* Connect to the server */
    if (connect(sock,(struct sockaddr *)&server,sizeof server) < 0) {
        perror("connecting to server");
        exit(4);
    }

    /* Finally, we can read and write on the socket. */
    /* ... */

    (void) close(sock);
}

在第一个示例中,我们将看到如何在 Java 中用基本上一行代码(加上一些错误处理)完成连接。然后,我们将讨论错误处理和通过套接字传输数据。接下来,我们将简要介绍一个实现了大部分已被用于 20 年来引导无盘工作站的datagram或 UDP 客户端的 TFTP(简单文件传输协议)。最后,我们将结束于一个连接到聊天服务器的交互式程序。

大多数这些客户端示例的共同主题是使用现有服务器,这样我们就不必同时生成客户端和服务器。这些大多数服务存在于任何标准 Unix 平台上。如果您找不到附近的 Unix 服务器来尝试它们,请允许我建议您拿一台旧 PC,也许是一台性能不足以运行最新 Microsoft 软件的 PC,并在其上安装一个免费的开源 Unix 系统。我个人最喜欢的是OpenBSD,市场上普遍喜欢的是 Linux。这两者都可以通过互联网免费安装,并提供所有在客户端示例中使用的标准服务,包括时间服务器和 TFTP。两者都有免费的 Java 实现可用。

我还提供了对 Web 服务客户端的基本覆盖。术语“Web 服务”现在已经意味着使用 HTTP 进行程序间通信。两个一般类别是基于 SOAP 和基于 REST。REST 服务非常简单 — 您发送一个 HTTP 请求,并获得纯文本或 JSON(第十四章)或 XML 格式的响应。SOAP 更为复杂,本书不涵盖。在 Elliotte Harold(O'Reilly)的Java 网络编程中有关客户端连接的更多信息。我不涵盖构建 Web 服务的服务器端 API — JAX-RS 和 JAX-WS,因为这些内容在几本 O'Reilly 书籍中有详细介绍。

12.1 HTTP/REST Web Client

问题

您需要从 URL 读取数据,例如连接到 RESTful Web 服务或通过 HTTP/HTTPS 下载网页或其他资源。

解决方案

使用标准 Java 11 的HttpClientURLConnection类。

这种技术适用于任何需要从 URL 读取数据的情况,不仅限于 RESTful Web 服务。

讨论

在 Java 11 之前,您必须使用URLConnection类或下载并使用旧版 Apache HTTP Client 库。使用 Java 11 后,标准 Java 中有一个相当易于使用和灵活的 API。它还支持 HTTP/2.0;而 Apache HttpClient截至 2020 年初尚不支持 HTTP/2.0,而传统的URLConnection也不太可能支持 HTTP/2.0。

以我们的简单示例为例,我们将使用 Google 的建议服务,即当您在 Google 网络搜索引擎中输入搜索的前几个字符时所看到的内容。

这项 Google 服务支持各种输出格式。基本 URL 如下:

https://suggestqueries.google.com/complete/search?client=firefox&q=

将您希望获得建议的单词附加到它。client=firefox告诉它我们需要一个简单的 JSON 格式;使用client=chrome它包含更多字段。

要使用 Java HTTP 客户端 API,您需要一个HttpClient对象,使用构建器模式获取,然后创建一个Request对象:

        // This object would be kept for the life of an application
        HttpClient client = HttpClient.newBuilder()
            .followRedirects(Redirect.NORMAL)
            .version(Version.HTTP_1_1)
            .build();

        // Build the HttpRequest object to "GET" the urlString
        HttpRequest req =
            HttpRequest.newBuilder(URI.create(urlString +
                URLEncoder.encode(keyword)))
            .header("User-Agent", "Dept of Silly Walks")
            .GET()
            .build();

HttpRequest对象可以使用客户端发送,以获取HttpResponse对象,从中您可以获取状态和/或正文。发送可以同步进行(如果您需要立即获得结果)或异步进行(如果在此期间可以有用地执行其他操作)。此示例显示了同时以同步和异步方式发送:

        // Send the request - synchronously
        HttpResponse<String> resp =
            client.send(req, BodyHandlers.ofString());

        // Collect the results
        if (resp.statusCode() == 200) {
            String response = resp.body();
            System.out.println(response);
        } else {
            System.out.printf("ERROR: Status %d on request %s\n",
                resp.statusCode(), urlString);
        }
        // Send the request - asynchronously
        client.sendAsync(req, BodyHandlers.ofString())
            .thenApply(HttpResponse::body)
            .thenAccept(System.out::println)
            .join();

这是输出;该行已在逗号处换行以适应页面:

$ java HttpClientDemo.java
["darwin",["darwin thompson","darwin","darwin awards","darwinism",
 "darwin australia","darwin thompson fantasy","darwin barney",
 "darwin theory","darwinai","darwin dormitorio"]]

如果您不想使用HttpClient库,则可以使用java.net中的旧代码,因为我们在这里通常只需要打开并从 URL 读取的能力。这是使用URLConnection的代码:

public class RestClientURLDemo {
    public static void main(String[] args) throws Exception {
        URLConnection conn = new URL(
            HttpClientDemo.urlString + HttpClientDemo.keyword)
            .openConnection();
        try (BufferedReader is =
            new BufferedReader(new InputStreamReader(conn.getInputStream()))) {

            String line;
            while ((line = is.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}

输出应该与HttpClient版本产生的完全一致。

参见

不要将此HttpClient较旧的 Apache HttpClient 库混淆。

您可以在 Bill Burke 的RESTful Java with JAX-RS 2.0, 2nd Edition(O’Reilly)中找到更多关于 REST 服务(包括为其实现服务器端组件)的信息。

12.2 联系套接字服务器

问题

您需要使用 TCP/IP 联系服务器。

解决方案

只需创建一个java.net.Socket,将主机名和端口号传递给构造函数。

讨论

在 Java 中并不复杂。创建套接字时,传递主机名和端口号。java.net.Socket构造函数执行gethostbyname()socket()系统调用,设置服务器的sockaddr_in结构,并执行connect()调用。您只需捕获错误,这些错误是从熟悉的IOException继承的子类。示例 12-2 设置了 Java 网络客户端,但实际上尚未执行任何 I/O 操作。它使用 try-with-resources 确保当我们完成时套接字会自动关闭。

示例 12-2. main/src/main/java/network/ConnectSimple.java(简单客户端连接)
import java.net.Socket;

/* Client with NO error handling */
public class ConnectSimple {

    public static void main(String[] argv) throws Exception {

        try (Socket sock = new Socket("localhost", 8080)) {

            /* If we get here, we can read and write on the socket "sock" */
            System.out.println(" *** Connected OK ***");

            /* Do some I/O here... */

        }
    }
}

此版本不进行实际错误报告,但名为ConnectFriendly的版本进行了;我们将在 Recipe 12.4 中看到此版本。

参见

Java 支持其他使用网络应用程序的方式。您还可以打开 URL 并从中读取(请参阅 Recipe 12.8)。您可以编写代码,以便在 Web 浏览器中打开时从 URL 运行,或者从应用程序中运行。

12.3 查找和报告网络地址

问题

您希望查找主机的地址名称或编号,或获取网络连接的另一端的地址。

解决方案

获取一个InetAddress对象。

讨论

InetAddress对象表示给定计算机或主机的互联网地址。它没有公共构造函数;您通过调用静态的getByName()方法获取InetAddress,传递主机名如darwinsys.com或网络地址作为字符串,如 1.23.45.67。该类中的所有“查找”方法都可以抛出已检查的UnknownHostExceptionjava.io.IOException的子类),必须在调用方法的头部捕获或声明。这些方法实际上不联系远程主机,因此它们不会抛出与网络连接相关的其他异常。

方法getHostAddress()给出与InetAddress对应的数值 IP 地址(作为字符串)。其反向是getHostName(),它报告InetAddress的名称。这可用于根据名称打印主机的地址,或反之亦然:

public class InetAddrDemo {
    public static void main(String[] args) throws IOException {
        String hostName = "darwinsys.com";
        String ipNumber = "8.8.8.8"; // currently a well-known Google DNS server

        // Show getting the InetAddress (looking up a host) by host name
        System.out.println(hostName + "'s address is " +
            InetAddress.getByName(hostName).getHostAddress());

        // Look up a host by address
        System.out.println(ipNumber + "'s name is " +
            InetAddress.getByName(ipNumber).getHostName());

        // Look up my localhost addresss
        final InetAddress localHost = InetAddress.getLocalHost();
        System.out.println("My localhost address is " + localHost);

        // Show getting the InetAddress from an open Socket
        String someServerName = "google.com";
        // assuming there's a web server on the named server:
        try (Socket theSocket = new Socket(someServerName, 80)) {
            InetAddress remote = theSocket.getInetAddress();
            System.out.printf("The InetAddress for %s is %s%n",
                someServerName, remote);
        }
    }
}

你还可以通过调用其getInetAddress()方法从Socket中获取InetAddress。你可以使用InetAddress而不是主机名字符串构造Socket。因此,要连接到与现有套接字上相同主机上的端口号myPortNumber,可以使用以下代码:

InetAddress remote = theSocket.getInetAddress( );
Socket anotherSocket = new Socket(remote, myPortNumber);

最后,要查找与主机关联的所有地址(服务器可能在多个网络上),请使用静态方法getAllByName(host),它返回一个InetAddress对象数组,每个 IP 地址关联一个给定名称。

静态方法getLocalHost()返回等同于localhost或 127.0.0.1 的InetAddress。这可用于连接到作为客户端正在运行的同一计算机上运行的服务器程序。

如果使用 IPv6,可以使用Inet6Address

另请参阅

参见第 13.2 节中的NetworkInterface,它允许您更多地了解正在运行的计算机的网络。目前标准 API 中没有查找服务的方法,也就是说,无法查找 HTTP 服务位于 80 端口的方法。TCP/IP 的完整实现始终包括一组额外的解析器;在 C 中,调用getservbyname("http", "tcp");将查找给定服务¹,并返回一个servent(服务条目)结构,其s_port成员将包含值 80。已建立服务的编号不会更改,但是当服务是新的或以非例行方式安装时,通过更改服务定义可以方便地更改机器或网络上所有程序的服务号码(无论编程语言如何)。Java 应在未来的发布版本中提供此功能。

12.4 处理网络错误

问题

如果出现问题,您需要比仅有IOException更详细的报告。

解决方案

捕获更多种类的异常类。SocketException有几个子类;最显著的是ConnectExceptionNoRouteToHostException。名称是不言自明的:第一个意味着连接被另一端的机器(服务器机器)拒绝,第二个完全解释了失败。示例 12-3 是Connect程序的摘录,增强了处理这些条件。

示例 12-3. ConnectFriendly.java
public class ConnectFriendly {
    public static void main(String[] argv) {
        String server_name = argv.length == 1 ? argv[0] : "localhost";
        int tcp_port = 80;
        try (Socket sock = new Socket(server_name, tcp_port)) {

            /* If we get here, we can read and write on the socket. */
            System.out.println(" *** Connected to " + server_name  + " ***");

            /* Do some I/O here... */

        } catch (UnknownHostException e) {
            System.err.println(server_name + " Unknown host");
            return;
        } catch (NoRouteToHostException e) {
            System.err.println(server_name + " Unreachable" );
            return;
        } catch (ConnectException e) {
            System.err.println(server_name + " connect refused");
            return;
        } catch (java.io.IOException e) {
            System.err.println(server_name + ' ' + e.getMessage());
            return;
        }
    }
}

12.5 读取和写入文本数据

问题

已连接,您希望传输文本数据。

解决方案

从套接字的getInputStream()getOutputStream()构造一个BufferedReaderPrintWriter

讨论

Socket类有允许您获取用于从套接字读取或写入的InputStreamOutputStream的方法。它没有获取ReaderWriter的方法,部分原因是一些网络服务仅限于 ASCII,但主要原因是在有ReaderWriter类之前就决定了Socket类。您可以始终使用转换类从InputStream创建Reader或从OutputStream创建Writer。这是两种最常见形式的范例:

BufferedReader is = new BufferedReader(
    new InputStreamReader(sock.getInputStream( )));
PrintWriter os = new PrintWriter(sock.getOutputStream( ), true);

示例 12-4 从白天服务读取一行文本,这种服务由全功能的 TCP/IP 套件(例如大多数 Unix 系统中包含的套件)提供。您不必向Daytime服务器发送任何内容;您只需连接并读取一行。服务器写入包含日期和时间的一行,然后关闭连接。

运行它看起来像以下代码。我首先在本地主机上获取当前日期和时间,然后运行DaytimeText程序以查看服务器(机器darian是我的 Unix 服务器之一)上的日期和时间:

C:\javasrc\network>date 
Current date is Sun 01-23-2000
Enter new date (mm-dd-yy):
C:\javasrc\network>time
Current time is  1:13:18.70p
Enter new time:
C:\javasrc\network>java network.DaytimeText darian
Time on darian is Sun Jan 23 13:14:34 2000

代码位于DaytimeText类中,显示在示例 12-4 中。

示例 12-4. DaytimeText.java
public class DaytimeText {
    public static final short TIME_PORT = 13;

    public static void main(String[] argv) {
        String server_name = argv.length == 1 ? argv[0] : "localhost";

        try (Socket sock = new Socket(server_name,TIME_PORT);
            BufferedReader is = new BufferedReader(new
                InputStreamReader(sock.getInputStream()));) {
            String remoteTime = is.readLine();
            System.out.println("Time on " + server_name + " is " + remoteTime);
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

第二个示例,显示在示例 12-5 中,显示了在同一个套接字上的读取和写入。Echo服务器简单地回显您发送的任何文本行。它不是一个非常聪明的服务器,但它是一个有用的服务器。它有助于网络测试,也有助于测试这类客户端!

converse()方法与名为主机上的Echo服务器进行简短对话;如果没有指定主机,则尝试联系localhost,这是程序正在运行的机器的通用别名²。

示例 12-5. main/src/main/java/network/EchoClientOneLine.java
public class EchoClientOneLine {
    /** What we send across the net */
    String mesg = "Hello across the net";

    public static void main(String[] argv) {
        if (argv.length == 0)
            new EchoClientOneLine().converse("localhost");
        else
            new EchoClientOneLine().converse(argv[0]);
    }

    /** Hold one conversation across the net */
    protected void converse(String hostName) {
        try (Socket sock = new Socket(hostName, 7);) { // echo server.
            BufferedReader is = new BufferedReader(new
                InputStreamReader(sock.getInputStream()));
            PrintWriter os = new PrintWriter(sock.getOutputStream(), true);
            // Do the CRLF ourself since println appends only a \r on
            // platforms where that is the native line ending.
            os.print(mesg + "\r\n"); os.flush();
            String reply = is.readLine();
            System.out.println("Sent \"" + mesg  + "\"");
            System.out.println("Got  \"" + reply + "\"");
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

将读取和写入代码从此方法中隔离出来,可能是一个好的练习,可以将其封装到一个NetWriter类中,可能是PrintWriter的子类,并添加\r\n和刷新操作。

12.6 读取和写入二进制或序列化数据

问题

已连接,您希望传输二进制数据,无论是原始二进制数据还是序列化的 Java 对象。

解决方案

对于普通的二进制日期,从套接字的getInputStream()getOutputStream()构造DataInputStreamDataOutputStream。对于序列化的 Java 对象数据,构造ObjectInputStreamObjectOutputStream

讨论

在套接字上读取/写入的最简单范式是:

DataInputStream is = new DataInputStream(sock.getInputStream());
DataOutputStream is = new DataOutputStream(sock.getOutputStream( ));

如果数据量可能很大,插入缓冲流以提高效率。这种范式是:

DataInputStream is = new DataInputStream(
    new BufferedInputStream(sock.getInputStream( )));
DataOutputStream is = new DataOutputStream(
    new BufferedOutputStream(sock.getOutputStream( )));

示例中的程序示例 12-6 使用另一个标准服务,以二进制整数表示自 1900 年以来的秒数,因为 Java Date类基于 1970 年,我们通过减去 1970 年和 1900 年之间的差异来转换时间基准。当我在课程中使用这个练习时,大多数学生希望添加这个时间差,理由是 1970 年更晚。但是如果你思考清楚,你会发现 1999 年和 1970 年之间的秒数比 1999 年和 1900 年之间的秒数少,所以减法给出了正确的秒数。并且因为Date构造函数需要毫秒,我们将秒数乘以 1000。

时间差是年数乘以 365,加上两个日期之间的闰年天数(在 1904 年、1908 年等年份中)——19 天。

我们从服务器读取的整数是 C 语言的unsigned int。但是 Java 不提供无符号整数类型;通常在需要无符号数字时,您使用下一个更大的整数类型,即long。但 Java 还没有提供从数据流中读取无符号整数的方法。DataInputStreamreadInt()方法读取 Java 风格的有符号整数。有readUnsignedByte()方法和readUnsignedShort()方法,但没有readUnsignedInt()方法。因此,我们通过读取无符号字节并使用 Java 的位移操作符重新组装它们,合成读取无符号int的能力(必须将其存储在long中,否则会丢失符号位并回到起点):

在代码的结尾,我们使用新的日期/时间 API(见第六章)构造并打印一个LocalDateTime对象,以显示本地(客户端)机器上的当前日期和时间:

$ date
Thu Dec 26 09:48:36 EST 2019
java network.RDateClient aragorn
Remote time is 3786360519
BASE_DIFF is 2208988800
Time diff == 1577371719
Time on aragorn is 2019-12-26T09:48:39
Local date/time = 2019-12-26T09:48:41.208180
$

名称aragorn是我 OpenBSD Unix 计算机之一的主机名。从输出中可以看出,服务器在一秒左右内达成一致。这证实了示例 12-6 中的日期计算代码。这种协议通常称为rdate,因此客户端代码称为RDateClient

示例 12-6. main/src/main/java/network/RDateClient.java
public class RDateClient {
    /** The TCP port for the binary time service. */
    public static final short TIME_PORT = 37;
    /** Seconds between 1970, the time base for dates and times
 * Factors in leap years (up to 2100), hours, minutes, and seconds.
 * Subtract 1 day for 1900, add in 1/2 day for 1969/1970.
 */
    protected static final long BASE_DAYS =
        (long)((1970-1900)*365 + (1970-1900-1)/4);

    /* Seconds since 1970 */
    public static final long BASE_DIFF = (BASE_DAYS * 24 * 60 * 60);

    public static void main(String[] argv) {
        String hostName;
        if (argv.length == 0)
            hostName = "localhost";
        else
            hostName = argv[0];

        try (Socket sock = new Socket(hostName,TIME_PORT);) {
            DataInputStream is = new DataInputStream(new
                BufferedInputStream(sock.getInputStream()));
            // Read 4 bytes from the network, unsigned.
            // Do it yourself; there is no readUnsignedInt().
            // Long is 8 bytes on Java, but we are using the
            // existing time protocol, which uses 4-byte ints.
            long remoteTime = (
                ((long)(is.readUnsignedByte()) << 24) |
                ((long)(is.readUnsignedByte()) << 16) |
                ((long)(is.readUnsignedByte()) <<  8) |
                ((long)(is.readUnsignedByte()) <<  0));
            System.out.println("Remote time is " + remoteTime);
            System.out.println("BASE_DIFF is " + BASE_DIFF);
            System.out.println("Time diff == " + (remoteTime - BASE_DIFF));
            Instant time = Instant.ofEpochSecond(remoteTime - BASE_DIFF);
            LocalDateTime d = LocalDateTime.ofInstant(time, ZoneId.systemDefault());
            System.out.println("Time on " + hostName + " is " + d.toString());
            System.out.println("Local date/time = " + LocalDateTime.now());
        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

对象序列化是将内存中的对象转换为可以逐字节发送的外部形式的能力。要通过序列化读取或写入 Java 对象,只需从InputStreamOutputStream构造ObjectInputStreamObjectOutputStream;在这种情况下,使用套接字的getInputStream()getOutputStream()

这个程序(及其服务器)提供的服务并不是 TCP/IP 协议栈的标准部分;这是我制作的一个演示服务。此服务的服务器在 Recipe 13.3 中介绍。Example 12-7 中的客户端代码与前一篇中的DaytimeBinary程序非常相似,但服务器发送给我们一个已构造好的LocalDateTime对象。Example 12-7 展示了与 Example 12-6 不同的客户端代码部分。

示例 12-7. main/src/main/java/network/DaytimeObject.java
        try (Socket sock = new Socket(hostName, TIME_PORT);) {
            ObjectInputStream is = new ObjectInputStream(new
                BufferedInputStream(sock.getInputStream()));

            // Read and validate the Object
            Object o = is.readObject();
            if (o == null) {
                System.err.println("Read null from server!");
            } else if ((o instanceof LocalDateTime)) {

                // Valid, so cast to LocalDateTime, and print
                LocalDateTime d = (LocalDateTime) o;
                System.out.println("Time on " + hostName + " is " + d);
            } else {
                throw new IllegalArgumentException(
                    String.format("Wanted LocalDateTime, got %s, a %s",
                        o, o.getClass()));
            }

我向操作系统询问日期和时间,然后运行程序,在远程机器上打印日期和时间:

$ date
Thu Dec 26 09:29:02 EST 2019
C:\javasrc\network>java network.DaytimeObject aragorn
Time on aragorn is 2019-12-26T09:29:05.227397
C:\javasrc\network>

再次结果在几秒钟内达成一致。

12.7 UDP 数据报

问题

你需要使用数据报连接(UDP)而不是流连接(TCP)。

解决方案

使用DatagramSocketDatagramPacket

讨论

数据报网络流量与底层基于数据包的以太网和 IP(Internet Protocol)层是一脉相承的。与 TCP 等基于流的连接不同,UDP 等数据报传输像发送单个数据包或数据块一样,作为一个单独的实体传输,与其他任何内容都没有必要的关系。³一个常见的比喻是,TCP 就像打电话,而 UDP 就像发送明信片或传真。

差异主要体现在错误处理上。数据包就像明信片一样,可能会丢失。你上次看到邮递员敲门告诉你邮局丢失了几张要送到你手中的明信片是什么时候?这种情况不会发生,因为邮局不会追踪明信片。另一方面,当你在电话上通话时,如果出现噪声爆发——比如有人在房间里大喊大叫,或者是连接不良——你会实时注意到故障,并可以要求对方重复刚才说的话。

对于像 TCP 套接字这样的基于流的连接,网络传输层会帮你处理错误:它会要求对方重新传输。但是对于 UDP 等数据报传输,你必须自己处理重传。这有点像编号你发送的明信片,这样你可以回头重新发送那些未到达的明信片——也许这是返回度假地的好借口。

另一个区别在于数据报传输保留了消息边界。也就是说,如果你使用 TCP 写入了 20 字节,然后写入了 10 字节,那么从另一端读取的程序将不知道你是写了 30 字节的一个块,还是两个 15 字节的块,甚至是 30 个单独的字符。使用DatagramSocket时,你为每个缓冲区构造一个DatagramPacket对象,其内容作为一个单独的实体通过网络发送;它的内容不会与任何其他缓冲区的内容混合在一起。DatagramPacket对象具有诸如getLength()setPort()的方法。

那么为什么我们会使用 UDP 呢? UDP 的开销比 TCP 少得多,在可靠的局域网或互联网上跳数较少时特别有价值。在长距离网络上,TCP 可能更受欢迎,因为 TCP 会为您处理丢失数据包的重传。显然,如果保留记录边界能够让您的生活更轻松,这可能是考虑使用 UDP 的原因。UDP 还是执行多播(同时向许多接收者广播)的方式,尽管多播超出了本讨论的范围。

示例 12-8 是一个简短的程序,通过 UDP 连接到配方 12.5 中使用的Daytime日期和时间服务器。因为 UDP 没有真正的连接概念,客户端通常会启动对话,有时意味着发送一个空包;UDP 服务器使用从中获取的地址信息来返回其响应。

示例 12-8. main/src/main/java/network/DaytimeUDP.java
public class DaytimeUDP {
    /** The UDP port number */
    public final static int DAYTIME_PORT = 13;

    /** A buffer plenty big enough for the date string */
    protected final static int PACKET_SIZE = 100;

    /** The main program that drives this network client.
 * @param argv[0] hostname, running daytime/udp server
 */
    public static void main(String[] argv) throws IOException {
        if (argv.length < 1) {
            System.err.println("usage: java DayTimeUDP host");
            System.exit(1);
        }
        String host = argv[0];
        InetAddress servAddr = InetAddress.getByName(host);
        DatagramSocket sock = new DatagramSocket();
        //sock.connect(servAddr, DAYTIME_PORT);
        byte[] buffer = new byte[PACKET_SIZE];

        // The udp packet we will send and receive
        DatagramPacket packet = new DatagramPacket(
            buffer, PACKET_SIZE, servAddr, DAYTIME_PORT);

        /* Send empty max-length (-1 for null byte) packet to server */
        packet.setLength(PACKET_SIZE-1);
        sock.send(packet);
        System.out.println("Sent request");

        // Receive a packet and print it.
        sock.receive(packet);
        System.out.println("Got packet of size " + packet.getLength());
        System.out.print("Date on " + host + " is " +
            new String(buffer, 0, packet.getLength()));

        sock.close();
    }
}

我会运行它到我的 Unix 框中,只是为了确保它工作:

$
$ java network.DaytimeUDP aragorn
Sent request
Got packet of size 26
Date on aragorn is Sat Feb  8 20:22:12 2014
$

12.8 URI、URL 或 URN?

问题

在听到这些术语之后,您想知道 URI、URL 和 URN 之间的区别。

解决方案

继续阅读。或查看java.net.uri的 javadoc。

讨论

URL 是传统的网络地址名称,由协议(如 HTTP)、地址(站点名称)和资源或路径名组成。但总共有三个不同的术语:

  • URI(统一资源标识符)

  • URL(统一资源定位符)

  • URN(统一资源名称)

Java 文档末尾的讨论解释了 URI、URL 和 URN 之间的关系。URI 形成了所有标识符的集合。URL 和 URN 是子集。

URI 是最通用的;URI 在不考虑其指定的方案(如果有)的情况下对基本语法进行解析,不需要引用特定的服务器。URL 包括主机名、方案和其他组件;该字符串根据其方案的规则进行解析。构造 URL 时,会自动创建一个InputStream。URN 命名资源但不说明如何定位它们;您可能看到的 URN 的典型示例包括mailto:news:引用。

URI类提供的主要操作是规范化(移除多余的路径段,包括“..”)和相对化(这应该称为“使相对化”,但某人希望用一个单词来作为方法名)。URI对象没有用于打开 URI 的任何方法;为此,通常会使用 URI 的字符串表示形式构造 URL 对象,如下所示:

URL x = new URL(theURI.toString( ));

示例 12-9 中的程序展示了从 URI 规范化、相对化以及构造 URL 的示例。

示例 12-9. main/src/main/java/network/URIDemo.java
public class URIDemo {
    public static void main(String[] args)
    throws URISyntaxException, MalformedURLException {

        URI u = new URI("https://darwinsys.com/java/../openbsd/../index.jsp");
        System.out.println("Raw: " + u);
        URI normalized = u.normalize();
        System.out.println("Normalized: " + normalized);
        final URI BASE = new URI("https://darwinsys.com");
        System.out.println("Relativized to " + BASE + ": " + BASE.relativize(u));

        // A URL is a type of URI
        URL url = new URL(normalized.toString());
        System.out.println("URL: " + url);

        // Demo of non-URL but valid URI
        URI uri = new URI("bean:WonderBean");
        System.out.println(uri);
    }
}

12.9 程序:TFTP UDP 客户端

这个程序实现了 TFTP 应用协议的客户端部分,这是一种曾经非常著名的服务,在 Unix 世界中用于工作站的网络引导,早在 Windows 3.1 之前就已经存在,现在主要用于计算机的网络引导。我选择这个协议是因为它在服务器端广泛实现,所以很容易找到用于测试的服务器。

TFTP 协议有些奇怪。客户端在众所周知的 UDP 端口号 69 上与服务器联系,使用生成的端口号,⁴,服务器从生成的端口号响应客户端。进一步的通信使用这两个生成的端口号。

更详细地讲,如图 12-1 所示,客户端首先发送包含文件名的读取请求,并读取第一个数据包。读取请求由两个字节(一个short)组成,带有读取请求代码(短整数,值为 1,定义为OP_RRQ),两个字节用于序列号,然后是 ASCII 文件名,以空字符结尾,和模式字符串,同样以空字符结尾。服务器从客户端读取读取请求,验证是否可以打开文件,并且如果可以,发送第一个数据包(OP_DATA),然后再次读取。客户端从其端口读取,并且如果读取正常,将数据包转换为确认包,并发送。这种读取-确认循环重复进行,直到读取所有数据。请注意,除了最后一个包,每个包都是 516 字节(512 字节的数据,加上 2 字节的包类型和另外 2 字节的包编号),最后一个包可以是任何长度,从 4(零字节数据)到 515(511 字节数据)。如果发生网络 I/O 错误,则重新发送该包。如果某个包偏离了轨道,客户端和服务器都应执行超时循环。这个客户端没有这样做,但服务器有。您可以使用线程(参见 Recipe 16.4)或通过在套接字上调用setSoTimeout()来添加超时,如果数据包丢失,则捕获SocketTimeoutException,重新传输确认(或读取请求),最多尝试某个最大次数。这留给读者作为练习。客户端代码的当前版本显示在示例 12-10 中。

jcb4 1201

图 12-1. TFTP 协议包格式
示例 12-10. main/src/main/java/network/RemCat.java
public class RemCat {
    /** The UDP port number */
    public final static int TFTP_PORT = 69;
    /** The mode we will use - octet for everything. */
    protected final String MODE = "octet";

    /** The offset for the code/response as a byte */
    protected final int OFFSET_REQUEST = 1;
    /** The offset for the packet number as a byte */
    protected final int OFFSET_PACKETNUM = 3;

    /** Debugging flag */
    protected static boolean debug = false;

    /** TFTP op-code for a read request */
    public final int OP_RRQ = 1;
    /** TFTP op-code for a read request */
    public final int OP_WRQ = 2;
    /** TFTP op-code for a read request */
    public final int OP_DATA = 3;
    /** TFTP op-code for a read request */
    public final int OP_ACK    = 4;
    /** TFTP op-code for a read request */
    public final int OP_ERROR = 5;

    protected final static int PACKET_SIZE = 516;    // == 2 + 2 + 512
    protected String host;
    protected InetAddress servAddr;
    protected DatagramSocket sock;
    protected byte buffer[];
    protected DatagramPacket inp, outp;

    /** The main program that drives this network client.
 * @param argv[0] hostname, running TFTP server
 * @param argv[1..n] filename(s), must be at least one
 */
    public static void main(String[] argv) throws IOException {
        if (argv.length < 2) {
            System.err.println("usage: rcat host filename[...]");
            System.exit(1);
        }
        if (debug)
            System.err.println("Java RemCat starting");
        RemCat rc = new RemCat(argv[0]);
        for (int i = 1; i<argv.length; i++) {
            if (debug)
                System.err.println("-- Starting file " +
                    argv[0] + ":" + argv[i] + "---");
            rc.readFile(argv[i]);
        }
    }

    RemCat(String host) throws IOException {
        super();
        this.host = host;
        servAddr = InetAddress.getByName(host);
        sock = new DatagramSocket();
        buffer = new byte[PACKET_SIZE];
        outp = new DatagramPacket(buffer, PACKET_SIZE, servAddr, TFTP_PORT);
        inp = new DatagramPacket(buffer, PACKET_SIZE);
    }

    /* Build a TFTP Read Request packet. This is messy because the
 * fields have variable length. Numbers must be in
 * network order, too; fortunately Java just seems
 * naturally smart enough :-) to use network byte order.
 */
    void readFile(String path) throws IOException {
        buffer[0] = 0;
        buffer[OFFSET_REQUEST] = OP_RRQ;        // read request
        int p = 2;            // number of chars into buffer

        // Convert filename String to bytes in buffer , using "p" as an
        // offset indicator to get all the bits of this request
        // in exactly the right spot.
        byte[] bTemp = path.getBytes();    // i.e., ASCII
        System.arraycopy(bTemp, 0, buffer, p, path.length());
        p += path.length();
        buffer[p++] = 0;        // null byte terminates string

        // Similarly, convert MODE ("stream" or "octet") to bytes in buffer
        bTemp = MODE.getBytes();    // i.e., ASCII
        System.arraycopy(bTemp, 0, buffer, p, MODE.length());
        p += MODE.length();
        buffer[p++] = 0;        // null terminate

        /* Send Read Request to tftp server */
        outp.setLength(p);
        sock.send(outp);

        /* Loop reading data packets from the server until a short
 * packet arrives; this indicates the end of the file.
 */
        do {
            sock.receive(inp);
            if (debug)
                System.err.println(
                    "Packet # " + Byte.toString(buffer[OFFSET_PACKETNUM])+
                    "RESPONSE CODE " + Byte.toString(buffer[OFFSET_REQUEST]));
            if (buffer[OFFSET_REQUEST] == OP_ERROR) {
                System.err.println("rcat ERROR: " +
                    new String(buffer, 4, inp.getLength()-4));
                return;
            }
            if (debug)
                System.err.println("Got packet of size " +
                    inp.getLength());

            /* Print the data from the packet */
            System.out.write(buffer, 4, inp.getLength()-4);

            /* Ack the packet. The block number we
 * want to ack is already in buffer so
 * we just change the opcode. The ACK is
 * sent to the port number which the server
 * just sent the data from, NOT to port
 * TFTP_PORT.
 */
            buffer[OFFSET_REQUEST] = OP_ACK;
            outp.setLength(4);
            outp.setPort(inp.getPort());
            sock.send(outp);
        } while (inp.getLength() == PACKET_SIZE);

        if (debug)
            System.err.println("** ALL DONE** Leaving loop, last size " +
                inp.getLength());
    }
}

要测试这个客户端,你需要一个 TFTP 服务器。如果你在管理的 Unix 系统上,你可以通过编辑文件/etc/inetd.conf并重新启动或重新加载inetd服务器(Linux 使用不同的机制,这可能因你所用的发行版而异)。inetd是一个程序,它监听各种连接,并在客户端连接时只启动服务器(一种惰性评估的方式)。⁵ 我设置了传统的/tftpboot目录,将这行放入我的inetd.conf中,并重新加载了inetd

tftp dgram udp wait root /usr/libexec/tftpd tftpd -s /tftpboot

然后我放了几个测试文件,其中一个命名为foo,放入/tftpboot目录中。运行

$ java network.RemCat localhost foo

产生看起来像文件的输出。但为了安全起见,我使用 Unix 的diff比较程序测试了RemCat的输出和原始文件。没有消息就是好消息:

$ java network.RemCat localhost foo | diff - /tftpboot/foo

到目前为止一切都很好。让我们不要在一个毫无防备的网络上运行这个程序,至少要简单地运行一下错误处理:

$ java network.RemCat localhost nosuchfile 
remcat ERROR: File not found
$

12.10 程序:基于套接字的聊天客户端

这个程序是一个简单的聊天程序。你不能用它打断 ICQ 或 AIM,因为它们各自使用自己的协议。⁶ 相反,这个程序只是向服务器写入和读取。这个服务器将在第十三章中介绍。运行时的效果如何?图 12-2 展示了我某天独自聊天的情况。

代码相当自解释。我们在一个线程中从远程服务器读取以使输入和输出不相互阻塞;这在第十六章讨论过。本章讨论了读取和写入。该程序在示例 12-11 中显示。

jcb4 1202

图 12-2. 聊天客户端运行中
示例 12-11. 主要/src/main/java/chat/ChatClient.java
public class ChatClient extends JFrame {

    private static final long serialVersionUID = -3686334002367908392L;
    private static final String userName =
        System.getProperty("user.name", "User With No Name");
    /** The state of logged-in-ness */
    protected boolean loggedIn;
    /* The main Frame. */
    protected JFrame cp;
    /** The default port number */
    protected static final int PORTNUM = ChatProtocol.PORTNUM;
    /** The actual port number */
    protected int port;
    /** The network socket */
    protected Socket sock;
    /** PrintWriter for sending lines on socket */
    protected PrintWriter pw;
    /** TextField for input */
    protected JTextField tf;
    /** TextArea to display conversations */
    protected JTextArea ta;
    /** The Login Button */
    protected JButton loginButton;
    /** The LogOUT button */
    protected JButton logoutButton;
    /** The TitleBar title */
    final static String TITLE = "ChatClient: Ian Darwin's Chat Room Client";

    final Executor threadPool = Executors.newSingleThreadExecutor();

    /** set up the GUI */
    public ChatClient() {
        cp = this;
        cp.setTitle(TITLE);
        cp.setLayout(new BorderLayout());
        port = PORTNUM;

        // The GUI
        ta = new JTextArea(14, 80);
        ta.setEditable(false);        // readonly
        ta.setFont(new Font("Monospaced", Font.PLAIN, 11));
        cp.add(BorderLayout.NORTH, ta);

        JPanel p = new JPanel();

        // The login button
        p.add(loginButton = new JButton("Login"));
        loginButton.setEnabled(true);
        loginButton.requestFocus();
        loginButton.addActionListener(e -> {
                login();
                loginButton.setEnabled(false);
                logoutButton.setEnabled(true);
                tf.requestFocus();    // set keyboard focus in right place!
        });

        // The logout button
        p.add(logoutButton = new JButton("Logout"));
        logoutButton.setEnabled(false);
        logoutButton.addActionListener(e -> {
                logout();
                loginButton.setEnabled(true);
                logoutButton.setEnabled(false);
                loginButton.requestFocus();
        });

        p.add(new JLabel("Message here:"));
        tf = new JTextField(40);
        tf.addActionListener(e -> {
                if (loggedIn) {
                    pw.println(ChatProtocol.CMD_BCAST+tf.getText());
                    tf.setText("");
                }
        });
        p.add(tf);

        cp.add(BorderLayout.SOUTH, p);

        cp.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        cp.pack();
    }

    protected String serverHost = "localhost";

    /** LOG ME IN TO THE CHAT */
    public void login() {
        /** BufferedReader for reading from socket */
        BufferedReader is;

        showStatus("In login!");
        if (loggedIn)
            return;
        try {
            sock = new Socket(serverHost, port);
            is = new BufferedReader(new InputStreamReader(sock.getInputStream()));
            pw = new PrintWriter(sock.getOutputStream(), true);
            showStatus("Got socket");

            // FAKE LOGIN FOR NOW - no password needed
            pw.println(ChatProtocol.CMD_LOGIN + userName);

            loggedIn = true;

        } catch(IOException e) {
            warn("Can't get socket to " +
                serverHost + "/" + port + ": " + e);
            cp.add(new JLabel("Can't get socket: " + e));
            return;
        }

        // Construct and start the reader: from server to textarea.
        // Make a Thread to avoid lockups.
        Runnable readerThread = new Runnable() {
            public void run() {
                String line;
                try {
                    while (loggedIn && ((line = is.readLine()) != null))
                        ta.append(line + "\n");
                } catch(IOException e) {
                    showStatus("Lost another client!\n" + e);
                    return;
                }
            }
        };
        threadPool.execute(readerThread);
    }

    /** Log me out, Scotty, there's no intelligent life here! */
    public void logout() {
        if (!loggedIn)
            return;
        loggedIn = false;
        try {
            if (sock != null)
                sock.close();
        } catch (IOException ign) {
            // so what?
        }
    }

    public void showStatus(String message) {
        System.out.println(message);
    }

    private void warn(String message) {
        JOptionPane.showMessageDialog(this, message);
    }

    /** A main method to allow the client to be run as an Application */
    public static void main(String[] args) {
        ChatClient room101 = new ChatClient();
        room101.pack();
        room101.setVisible(true);
    }
}

另请参阅

有许多更好结构化的方法来编写聊天客户端,包括 WebSockets、RMI 和 JMS。RMI 是 Java 的 RPC 接口,包含在 Java SE 和 Java EE 中;这本书的这一版中没有描述它,但你可以在我的网站找到先前版本的 RMI 章节。其他技术是 Java 企业的一部分,因此我再次推荐阅读 Arun Gupta 的Java EE 7 Essentials

如果你的通信经过公共互联网,你确实需要加密你的套接字连接,所以请查看 Sun 的 JSSE(Java 安全套接字扩展)。如果你听从我的建议并使用标准的 HTTP 协议,你可以通过将 URL 更改为 https 来加密会话。

对于从 C 程序员的角度对网络编程有良好概述,请参阅已故的 W·理查德·史蒂文斯(W. Richard Stevens)的《Unix 网络编程》(Prentice Hall)。尽管书名为 Unix 网络编程,但实际上是关于套接字、TCP/IP 和 UDP 编程,并详细介绍了所有(Unix)网络 API 及协议,如 TFTP。

12.11 程序:简单 HTTP 链接检查器

检查链接是网站所有者及撰写链接到外部来源技术文档(例如,您正在阅读的书的作者)的人员面临的持续问题。链接检查工具是他们验证页面链接的必备工具,无论是网页还是书页。实现链接检查器基本上是(a)提取链接和(b)打开它们的事情。因此,我们有了示例 12-12 中的程序。我称之为KwikLinkChecker,因为它有点快速且粗糙——它不验证链接内容是否仍然包含原始内容;因此,例如,如果一个开源项目忘记更新其域名注册,而其域名被一个色情网站接管,那么KwikLinkChecker将永远不会知道。但话虽如此,它完成了它的工作,而且相当快速。

示例 12-12. darwinsys-api/src/main/java/com/darwinsys/tools/KwikLinkChecker.java
    /**
 * Check one HTTP link; not recursive. Returns a LinkStatus with
 * boolean success, and the filename or an error message in the
 * message part of the LinkStatus.  The end of this method is one of
 * the few places where a whole raft of different "catch" clauses is
 * actually needed for the intent of the program.
 * @param urlString the link to check
 * @return the link's status
 */
    @SuppressWarnings("exports")
    public LinkStatus check(String urlString) {
        try {
            HttpResponse<String> resp = client.send(
                HttpRequest.newBuilder(URI.create(urlString))
                .header("User-Agent", getClass().getName())
                .GET()
                .build(),
                BodyHandlers.ofString());

            // Collect the results
            if (resp.statusCode() == 200) {
                System.out.println(resp.body());
            } else {
                System.out.printf("ERROR: Status %d on request %s\n",
                    resp.statusCode(), urlString);
            }

            switch (resp.statusCode()) {
            case 200:
                return new LinkStatus(true, urlString);
            case 403:
                return new LinkStatus(false,"403: " + urlString );
            case 404:
                return new LinkStatus(false,"404: " + urlString );
            }
            return new LinkStatus(true, urlString);
        } catch (IllegalArgumentException | MalformedURLException e) {
            // JDK throws IAE if host can't be determined from URL string
            return new LinkStatus(false, "Malformed URL: " + urlString);
        } catch (UnknownHostException e) {
            return new LinkStatus(false, "Host invalid/dead: " + urlString);
        } catch (FileNotFoundException e) {
            return new LinkStatus(false,"NOT FOUND (404) " + urlString);
        } catch (ConnectException e) {
            return new LinkStatus(false, "Server not listening: " + urlString);
        } catch (SocketException e) {
            return new LinkStatus(false, e + ": " + urlString);
        } catch (IOException e) {
            return new LinkStatus(false, e.toString()); // includes failing URL
        } catch (Exception e) {
            return new LinkStatus(false, urlString + ": " + e);
        }
    }

当然还有更复杂的链接检查工具可用,但这个对我来说就够用了。

¹ 它被查找的位置各不相同。在 Unix 上,它可能位于名为/etc/services的文件中;在 Windows 的\或 _\winnt子目录的services文件中;或在像 Sun 的网络信息服务(NIS,曾称为 YP)这样的集中注册表中;或在某些其他平台或网络相关位置。

² 在大多数网络系统由全职系统人员管理并接受培训或见习时,这曾是普遍的。今天,互联网上许多机器并未正确配置localhost

³ 某些网络可能需要对 UDP 数据包进行分段,但这对我们在 UDP 层级上不重要,因为它将在另一端将网络数据包重新组装为我们的单实体 UDP 数据包。

⁴ 当应用程序不关心时,这些端口号通常由操作系统生成。例如,当您从公用电话或手机拨打公司电话时,公司通常不在乎您从哪个号码打过来,如果在乎的话,也有办法找出来。生成的端口号通常范围从 1024(第一个非特权端口;参见 Chapter 13)到 65535(在 16 位端口号中可以表示的最大值)。

⁵ 警惕安全漏洞;不要在互联网上放任一个 TFTP 服务器,而不先读一本好的安全书,比如构建互联网防火墙,作者为 D. Chapman 等人(O’Reilly)。

⁶ 如果你想要一个开源程序,提供 IM 服务,让你从同一个程序中进行通话,请查看 Jabber,网址为http://www.jabber.org

第十三章:Java 服务器端

13.0 引言

套接字构成几乎所有网络协议的基础。JDBC、RMI、CORBA、EJB,以及非 Java 的 RPC(远程过程调用)和 NFS(网络文件系统),所有这些都通过连接各种类型的套接字来实现。套接字连接可以在几乎任何语言中实现,不仅限于 Java:C、C++、Perl 和 Python 也很流行,还有许多其他可能性。任何一种语言编写的客户端或服务器都可以与用其他任何一种语言编写的对方通信。因此,即使最终使用了诸如 RMI、JDBC、CORBA 或 EJB 等高级服务,快速了解 ServerSocket 的行为也是值得的。

讨论首先关注 ServerSocket 本身,然后介绍了多种方式在套接字上写入数据。最后,我展示了一个完整的可用网络服务器的实现示例,这是前一章中客户端的聊天服务器的实现。

提示

大多数服务器端 Java 生产工作使用 Java Enterprise Edition(Java EE),最近从 Oracle 转移到 Eclipse Software Foundation 并更名为 Jakarta,但广泛使用其先前的名称(偶尔也使用其非常古老的名称“J2EE”,该名称已于 2005 年停用)。Java EE 提供可扩展性和支持构建良构化的多层分布式应用程序。EE 提供 Servlet 框架;Servlet 是可以安装到任何标准 Java EE Web 服务器中的策略对象。EE 还提供两种 Web 视图技术:原始的 JSP(JavaServer Pages)和较新的基于组件的 JSF(JavaServer Faces)。最后,EE 还提供许多其他基于网络的服务,包括 EJB3 远程访问和 Java Messaging Service(JMS)。这些内容超出了本书的范围;它们在其他书籍中有所涵盖,例如 Arun Gupta 的 Java EE 7 Essentials: Enterprise Developer Handbook。本章仅适用于那些需要或希望从头开始构建自己服务器的人。

13.1 开启一个用于业务的服务器套接字

问题

需要编写基于套接字的服务器。

解决方案

为给定的端口号创建 ServerSocket

讨论

ServerSocket 表示连接的另一端,即等待客户端连接的服务器。你只需用端口号构造一个 ServerSocket。¹ 由于它不需要连接到另一台主机,所以不像客户端套接字构造函数那样需要特定主机的地址。

假设ServerSocket构造函数不会抛出异常,您就可以开始工作了。您的下一步是等待客户端活动,这可以通过调用accept()来实现。此调用将阻塞,直到客户端连接到您的服务器;此时,accept()将向您返回一个Socket对象(而不是ServerSocket),该对象在客户端的ServerSocket对象(或其等价对象,如果用另一种语言编写)中双向连接。示例 13-1 展示了基于套接字的服务器的代码。

示例 13-1. main/src/main/java/network/Listen.java
public class Listen {
    /** The TCP port for the service. */
    public static final short PORT = 9999;

    public static void main(String[] argv) throws IOException {
        ServerSocket sock;
        Socket  clientSock;
        try {
            sock = new ServerSocket(PORT);
            while ((clientSock = sock.accept()) != null) {

                // Process it, usually on a separate thread
                // to avoid blocking the accept() call.
                process(clientSock);
            }

        } catch (IOException e) {
            System.err.println(e);
        }
    }

    /** This would do something with one client. */
    static void process(Socket s) throws IOException {
        System.out.println("Accept from client " + s.getInetAddress());
        // The conversation would be here.
        s.close();
    }
}

通常,您会在读取和写入时使用相同的套接字,如下几个示例所示。

您可能只想侦听特定的网络接口。尽管我们倾向于将网络地址视为计算机地址,但两者并不相同。网络地址实际上是给定计算设备上的特定网络卡或网络接口连接的地址。台式计算机、笔记本电脑、平板电脑或手机可能只有一个接口,因此只有一个网络地址。但是大型服务器可能有两个或更多接口,通常当它连接到多个网络时。网络路由器是一个盒子,可以是专用用途的(例如 Cisco 路由器),也可以是通用用途的(例如 Unix 主机),它在多个网络上都有接口,并且具有转发数据包的能力和管理权限。在这样的服务器上运行的程序可能希望仅向其内部网络或外部网络提供服务。通过指定要侦听的网络接口,可以实现这一目标。假设您希望为内部网提供与外部客户不同的网页视图。出于安全原因,您可能不会在同一台机器上运行这两种服务。但是如果您希望这样做,可以通过将网络接口地址作为参数提供给ServerSocket构造函数来实现。

然而,要使用构造函数的这种形式,您不能像客户端套接字那样使用字符串作为网络地址的名称;您必须将其转换为InetAddress对象。您还必须提供一个 backlog 参数,这是在客户端被告知您的服务器太忙之前可以排队等待接受的连接数。完整的设置如示例 13-2 所示。

示例 13-2. main/src/main/java/network/ListenInside.java
public class ListenInside {
    /** The TCP port for the service. */
    public static final short PORT = 9999;
    /** The name of the network interface. */
    public static final String INSIDE_HOST = "acmewidgets-inside";
    /** The number of clients allowed to queue */
    public static final int BACKLOG = 10;

    public static void main(String[] argv) throws IOException {
        ServerSocket sock;
        Socket  clientSock;
        try {
            sock = new ServerSocket(PORT, BACKLOG,
                InetAddress.getByName(INSIDE_HOST));
            while ((clientSock = sock.accept()) != null) {

                // Process it.
                process(clientSock);
            }

        } catch (IOException e) {
            System.err.println(e);
        }
    }

    /** Hold server's conversation with one client. */
    static void process(Socket s) throws IOException {
        System.out.println("Connected from  " + INSIDE_HOST +
            ": " + s.getInetAddress(  ));
        // The conversation would be here.
        s.close();
    }
}

InetAddress.getByName()以系统相关的方式查找给定主机名,在/etc\windows目录下的配置文件中,或者通过诸如域名系统这样的解析器来引用。如果需要修改此数据,请参考有关网络和系统管理的好书。

13.2 查找网络接口

问题

您希望了解计算机的网络安排。

解决方案

使用NetworkInterface类。

讨论

网络中的每台计算机都有一个或多个“网络接口”。在典型的台式机上,网络接口代表网络卡或网络端口,或者某些软件网络接口,如环回接口。每个接口都有一个操作系统定义的名称。在大多数 Unix 版本中,这些设备有一个两个或三个字符的设备驱动程序名称加上一个数字(从 0 开始),例如,eth0en0表示第一台以太网设备,系统隐藏了卡片制造商的细节;或者de0de1表示第一和第二个基于 Digital Equipment 的 DC21x4x 以太网卡,xl0表示 3Com EtherLink XL,等等。环回接口在所有类 Unix 平台上几乎都是lo0

那又怎样?大多数情况下这对你来说无关紧要。如果你只有一个网络连接,比如与 ISP 的电缆连接,你真的不在乎。这在服务器上很重要,例如你可能需要找到特定网络的地址。NetworkInterface类允许你找到。它具有用于列出接口的静态方法和用于查找与给定接口关联的地址的其他方法。示例 Example 13-3 中的程序展示了使用此类的一些示例。运行它会打印所有本地接口的名称。如果你恰好在名为laptop的计算机上,它会打印机器的网络地址;如果不是,你可能想要从命令行接受本地计算机的名称;这留给读者作为练习。

示例 13-3. main/src/main/java/network/NetworkInterfaceDemo.java
public class NetworkInterfaceDemo {
    public static void main(String[] a) throws IOException {
        Enumeration<NetworkInterface> list =
            NetworkInterface.getNetworkInterfaces();
        while (list.hasMoreElements()) {
            // Get one NetworkInterface
            NetworkInterface iface = list.nextElement();
            // Print its name
            System.out.println(iface.getDisplayName());
            Enumeration<InetAddress> addrs = iface.getInetAddresses();
            // And its address(es)
            while (addrs.hasMoreElements()) {
                InetAddress addr = addrs.nextElement();
                System.out.println(addr);
            }

        }
        // Try to get the Interface for a given local (this machine's) address
        InetAddress destAddr = InetAddress.getByName("laptop");
        try {
            NetworkInterface dest = NetworkInterface.getByInetAddress(destAddr);
            System.out.println("Address for " + destAddr + " is " + dest);
        } catch (SocketException ex) {
            System.err.println("Couldn't get address for " + destAddr);
        }
    }
}

13.3 返回响应(字符串或二进制)

问题

你需要向客户端写入字符串或二进制数据。

解决方案

套接字提供了一个InputStream和一个OutputStream。使用它们。

讨论

上一章中的客户端套接字示例调用了getInputStream()getOutputStream()方法。这些示例也一样。主要区别在于,这些示例从ServerSocketaccept()方法中获取套接字。另一个区别是,按照定义,通常是服务器创建或修改数据并将其发送到客户端。Example 13-4 是一个简单的Echo服务器,Recipe 12.5 中的Echo客户端可以连接到它。此服务器处理一个完整的客户端连接,然后返回并等待下一个客户端的accept()

示例 13-4. main/src/main/java/network/EchoServer.java
public class EchoServer {
    /** Our server-side rendezvous socket */
    protected ServerSocket sock;
    /** The port number to use by default */
    public final static int ECHOPORT = 7;
    /** Flag to control debugging */
    protected boolean debug = true;

    /** main: construct and run */
    public static void main(String[] args) {
        int p = ECHOPORT;
        if (args.length == 1) {
            try {
                p = Integer.parseInt(args[0]);
            } catch (NumberFormatException e) {
                System.err.println("Usage: EchoServer [port#]");
                System.exit(1);
            }
        }
        new EchoServer(p).handle();
    }

    /** Construct an EchoServer on the given port number */
    public EchoServer(int port) {
        try {
            sock = new ServerSocket(port);
        } catch (IOException e) {
            System.err.println("I/O error in setup");
            System.err.println(e);
            System.exit(1);
        }
    }

    /** This handles the connections */
    protected void handle() {
        Socket ios = null;
        while (true) {
            try {
                System.out.println("Waiting for client...");
                ios = sock.accept();
                System.err.println("Accepted from " +
                    ios.getInetAddress().getHostName());
                try (BufferedReader is = new BufferedReader(
                            new InputStreamReader(ios.getInputStream(), "8859_1"));
                        PrintWriter os = new PrintWriter(
                            new OutputStreamWriter(ios.getOutputStream(), "8859_1"),
                            true);) {
                    String echoLine;
                    while ((echoLine = is.readLine()) != null) {
                        System.err.println("Read " + echoLine);
                        os.print(echoLine + "\r\n");
                        os.flush();
                        System.err.println("Wrote " + echoLine);
                    }
                    System.err.println("All done!");
                }
            } catch (IOException e) {
                System.err.println(e);
            }
        }
        /* NOTREACHED */
    }
}

为了在任意网络连接上发送字符串,一些权威建议同时发送回车和换行字符;许多协议规范要求如此做。这就解释了代码中的\r\n。如果另一端是 DOS 程序或类似 Telnet 的程序,可能期望同时接收这两个字符。另一方面,如果你同时编写两端,可以简单地使用println()——在读取之前始终紧接着显式地使用flush(),以防止出现一端的数据仍在PrintWriter缓冲区中导致死锁的情况!

如果需要处理二进制数据,请使用java.io中的数据流而不是读取器/写入器。我需要一个服务器用于食谱 12.6 的DaytimeBinary程序。在操作中,它应该如下所示:

C:\javasrc\network>java network.DaytimeBinary
Remote time is 3161316799
BASE_DIFF is 2208988800
Time diff == 952284799
Time on localhost is Sun Mar 08 19:33:19 GMT 2014

C:\javasrc\network>time/t
Current time is  7:33:23.84p

C:\javasrc\network>date/t
Current date is Sun 03-08-2014

C:\javasrc\network>

嗯,我的武器库中正好有这样一个程序,所以我在示例 13-5 中呈现它。请注意,它直接使用了客户端类中定义的某些公共常量。通常这些常量在服务器类中定义并由客户端使用,但我想先呈现客户端代码。

示例 13-5. main/src/main/java/network/DaytimeServer.java
public class DaytimeServer {
    /** Our server-side rendezvous socket */
    ServerSocket sock;
    /** The port number to use by default */
    public final static int PORT = 37;

    /** main: construct and run */
    public static void main(String[] argv) {
        new DaytimeServer(PORT).runService();
    }

    /** Construct a DaytimeServer on the given port number */
    public DaytimeServer(int port) {
        try {
            sock = new ServerSocket(port);
        } catch (IOException e) {
            System.err.println("I/O error in setup\n" + e);
            System.exit(1);
        }
    }

    /** This handles the connections */
    protected void runService() {
        Socket ios = null;
        DataOutputStream os = null;
        while (true) {
            try {
                System.out.println("Waiting for connection on port " + PORT);
                ios = sock.accept();
                System.err.println("Accepted from " +
                    ios.getInetAddress().getHostName());
                os = new DataOutputStream(ios.getOutputStream());
                long time = System.currentTimeMillis();

                time /= 1000;    // Daytime Protocol is in seconds

                // Convert to Java time base.
                time += RDateClient.BASE_DIFF;

                // Write it, truncating cast to int since it is using
                // the Internet Daytime protocol which uses 4 bytes.
                // This will fail in the year 2038, along with all
                // 32-bit timekeeping systems based from 1970.
                // Remember, you read about the Y2038 crisis here first!
                os.writeInt((int)time);
                os.close();
            } catch (IOException e) {
                System.err.println(e);
            }
        }
    }
}

13.4 在网络连接中返回对象信息

问题

你需要通过网络连接返回一个对象。

解决方案

创建所需的对象,并使用套接字输出流顶部的ObjectOutputStream将其写入。

讨论

前一章节中示例 12-7 的程序读取一个Date对象,使用ObjectInputStream。示例 13-6,DaytimeObjectServer(该过程的另一端),是一个每次连接时构造一个Date对象并返回给客户端的程序。

示例 13-6. main/src/main/java/network/DaytimeObjectServer.java
public class DaytimeObjectServer {
    /** The TCP port for the object time service. */
    public static final short TIME_PORT = 1951;

    public static void main(String[] argv) {
        ServerSocket sock;
        Socket  clientSock;
        try {
            sock = new ServerSocket(TIME_PORT);
            while ((clientSock = sock.accept()) != null) {
                System.out.println("Accept from " +
                    clientSock.getInetAddress());
                ObjectOutputStream os = new ObjectOutputStream(
                    clientSock.getOutputStream());

                // Construct and write the Object
                os.writeObject(LocalDateTime.now());

                os.close();
            }

        } catch (IOException e) {
            System.err.println(e);
        }
    }
}

13.5 处理多个客户端

问题

你的服务器需要处理多个客户端。

解决方案

对每个使用一个线程。

讨论

在 C 语言世界中,有几种机制允许服务器处理多个客户端。其中一种是使用特殊的系统调用select()poll(),它通知服务器哪些文件/套接字描述符准备好读取、准备好写入或有错误。通过在这个列表中包括它的约会套接字(相当于我们的ServerSocket),基于 C 的服务器可以按任何顺序从多个客户端读取。Java 不提供这个调用,因为它在某些 Java 平台上不容易实现。相反,Java 使用通用的Thread机制,如第十六章所述(线程现在在许多编程语言中很常见,尽管不总是以这个名称)。每当代码从ServerSocket接受新连接时,它立即构造并启动一个新的线程对象来处理该客户端。³

实现在套接字上接受的 Java 代码非常简单,除了必须捕获 IOException 外:

/** Run the main loop of the Server. */
void runServer( ) {
    while (true) {
        try {
            Socket clntSock = sock.accept( );
            new Handler(clntSock).start( );
        } catch(IOException e) {
            System.err.println(e);
        }
    }
}

要使用线程,你必须要么继承 Thread 类,要么实现 Runnable 接口。为了使这段代码按照原样运行,Handler 类必须是 Thread 的子类;如果 Handler 实现了 Runnable 接口,那么代码将会把 Runnable 的实例传递给 Thread 的构造函数,就像这样:

Thread t = new Thread(new Handler(clntSock));
t.start( );

但按照原样,Handler 是使用 accept() 返回的普通套接字构造的,并且通常调用套接字的 getInputStream()getOutputStream() 方法,以正常方式进行通信。我将展示一个完整的实现,一个多线程回显客户端。首先,一个显示其使用情况的会话:

$ java network.EchoServerThreaded
EchoServerThreaded ready for connections.
Socket starting: Socket[addr=localhost/127.0.0.1,port=2117,localport=7]
Socket starting: Socket[addr=darian/192.168.1.50,port=13386,localport=7]
Socket starting: Socket[addr=darian/192.168.1.50,port=22162,localport=7]
Socket ENDED: Socket[addr=darian/192.168.1.50,port=22162,localport=7]
Socket ENDED: Socket[addr=darian/192.168.1.50,port=13386,localport=7]
Socket ENDED: Socket[addr=localhost/127.0.0.1,port=2117,localport=7]

在这里,我使用我的 EchoClient 程序连接了一次服务器,并且在仍然连接的情况下,使用操作系统提供的 Telnet 客户端多次调用它。服务器同时与所有客户端通信,将第一个客户端的答复发送回给第一个客户端,将第二个客户端的数据发送回给第二个客户端。简而言之,它有效果。我在程序中使用文件结束符号结束了会话,并使用 Telnet 客户端的正常断开机制。示例 13-7 是服务器的代码。

示例 13-7. 主代码/src/main/java/network/EchoServerThreaded.java
public class EchoServerThreaded {

    public static final int ECHOPORT = 7;

    public static void main(String[] av) {
        new EchoServerThreaded().runServer();
    }

    public void runServer() {
        ServerSocket sock;
        Socket clientSocket;

        try {
            sock = new ServerSocket(ECHOPORT);

            System.out.println("EchoServerThreaded ready for connections.");

            /* Wait for a connection */
            while (true) {
                clientSocket = sock.accept();
                /* Create a thread to do the communication, and start it */
                new Handler(clientSocket).start();
            }
        } catch (IOException e) {
            /* Crash the server if IO fails. Something bad has happened */
            System.err.println("Could not accept " + e);
            System.exit(1);
        }
    }

    /** A Thread subclass to handle one client conversation. */
    class Handler extends Thread {
        Socket sock;

        Handler(Socket s) {
            sock = s;
        }

        public void run() {
            System.out.println("Socket starting: " + sock);
            try (BufferedReader is = new BufferedReader(
                        new InputStreamReader(sock.getInputStream()));
                    PrintStream os = new PrintStream(
                        sock.getOutputStream(), true);) {
                String line;
                while ((line = is.readLine()) != null) {
                    os.print(line + "\r\n");
                    os.flush();
                }
                sock.close();
            } catch (IOException e) {
                System.out.println("IO Error on socket " + e);
                return;
            }
            System.out.println("Socket ENDED: " + sock);
        }
    }
}

大量的短交易可能会降低性能,因为每个客户端都会导致创建一个新的线程对象。如果你知道或者可以可靠地预测所需的并发度,另一种范例涉及预先创建固定数量的线程。但是你如何控制它们对ServerSocket的访问呢?查看ServerSocket类文档会发现accept()方法没有同步,这意味着任何数量的线程可以同时调用该方法。这可能会导致糟糕的事情发生。因此,我在此调用周围使用synchronized关键字来确保一次只有一个客户端在其中运行,因为它更新全局数据。当没有客户端连接时,你将会有一个(随机选择的)线程在ServerSocket对象的accept()方法中运行,等待连接,加上n-1个线程等待第一个线程从方法返回。一旦第一个线程成功接受连接,它就会离开并进行对话,释放其锁,以便另一个随机选择的线程被允许进入accept()方法。每个线程的run()方法都有一个从accept()开始的无限循环,然后进行对话。结果是客户端连接可以更快地启动,但稍微增加了服务器启动时间。这样做还可以避免每次请求到来时构造一个新的HandlerThread对象的开销。这种一般方法类似于流行的 Apache Web 服务器所做的,尽管它通常会创建一组相同的进程(而不是线程)来处理客户端连接。因此,我已经修改了示例 13-7 中显示的EchoServerThreaded类,使其以这种方式工作,你可以在示例 13-8 中看到。

示例 13-8. main/src/main/java/network/EchoServerThreaded2.java
public class EchoServerThreaded2 {

    public static final int ECHOPORT = 7;

    public static final int NUM_THREADS = 4;

    /** Main method, to start the servers. */
    public static void main(String[] av) {
        new EchoServerThreaded2(ECHOPORT, NUM_THREADS);
    }

    /** Constructor */
    public EchoServerThreaded2(int port, int numThreads) {
        ServerSocket servSock;

        try {
            servSock = new ServerSocket(port);

        } catch (IOException e) {
            /* Crash the server if IO fails. Something bad has happened */
            throw new RuntimeException("Could not create ServerSocket ", e);
        }

        // Create a series of threads and start them.
        for (int i = 0; i < numThreads; i++) {
            new Handler(servSock, i).start();
        }
    }

    /** A Thread subclass to handle one client conversation. */
    class Handler extends Thread {
        ServerSocket servSock;
        int threadNumber;

        /** Construct a Handler. */
        Handler(ServerSocket s, int i) {
            servSock = s;
            threadNumber = i;
            setName("Thread " + threadNumber);
        }

        public void run() {
            /*
 * Wait for a connection. Synchronized on the ServerSocket while
 * calling its accept() method.
 */
            while (true) {
                try {
                    System.out.println(getName() + " waiting");

                    Socket clientSocket;
                    // Wait here for the next connection.
                    synchronized (servSock) {
                        clientSocket = servSock.accept();
                    }
                    System.out.println(
                        getName() + " starting, IP=" +
                        clientSocket.getInetAddress());
                    try (BufferedReader is = new BufferedReader(
                            new InputStreamReader(clientSocket.getInputStream()));
                            PrintStream os = new PrintStream(
                                clientSocket.getOutputStream(), true);) {
                        String line;
                        while ((line = is.readLine()) != null) {
                            os.print(line + "\r\n");
                            os.flush();
                        }
                        System.out.println(getName() + " ENDED ");
                        clientSocket.close();
                    }
                } catch (IOException ex) {
                    System.out.println(getName() + ": IO Error on socket " + ex);
                    return;
                }
            }
        }
    }
}

用 NIO 实现这种服务器是完全可能的,这是“新的”(在 J2SE 1.4 时)I/O 包。然而,要做到这一点的代码超过了本章的任何内容,并且充满了问题。有几篇关于如何利用 NIO 管理服务器连接获得性能提升的好教程可以在互联网上找到。

13.6 提供 HTTP 协议

问题

你想要提供像 HTTP 这样的协议。

解决方案

创建一个ServerSocket,并编写一些能够使用特定协议的代码。或者更好的是,使用一个 Java 驱动的 Web 服务器,比如 Apache Tomcat 或 Java 企业版(Java EE)服务器,比如 JBoss WildFly。

讨论

你可以为非常简单的应用程序实现自己的 HTTP 协议服务器,我们将在这里做到这一点。对于任何严肃的开发,你都想要使用 Java 企业版;请参阅本章开头的说明。

这个例子只是构造了一个ServerSocket并侦听它。当连接进来时,它们会使用 HTTP 协议进行回复。因此,它比简单的Echo服务器更复杂,后者在 Recipe 13.3 中有所介绍。然而,这不是一个完整的 Web 服务器;请求中的文件名被忽略,并且总是返回标准消息。因此,这是一个非常简单的 Web 服务器;它只遵循发送响应所需的 HTTP 协议的最低要求。要获取用 Java 编写的真正的 Web 服务器,请从Apache Tomcat 网站或任何 Jakarta/JavaEE 应用服务器中获取 Tomcat。然而,Example 13-9 中显示的代码足以理解如何构建一个使用协议响应请求的简单服务器。

示例 13-9. main/src/main/java/network/WebServer0.java
public class WebServer0 {
    public static final int HTTP = 80;
    public static final String CRLF = "\r\n";
    ServerSocket s;
    /** A link to the source of this program, used in error message */
    static final String VIEW_SOURCE_URL =
    "https://github.com/IanDarwin/javasrc/tree/master/main/src/main/
 java/network";

    /**
 * Main method, just creates a server and call its runServer().
 */
    public static void main(String[] args) throws Exception {
        System.out.println("DarwinSys JavaWeb Server 0.0 starting...");
        WebServer0 w = new WebServer0();
        int port = HTTP;
        if (args.length == 1) {
            port = Integer.parseInt(args[0]);
            }
        w.runServer(port);        // never returns!!
    }

    /** Get the actual ServerSocket; deferred until after Constructor
 * so subclass can mess with ServerSocketFactory (e.g., to do SSL).
 * @param port The port number to listen on
 */
    protected ServerSocket getServerSocket(int port) throws Exception {
        return new ServerSocket(port);
    }

    /** RunServer accepts connections and passes each one to handler. */
    public void runServer(int port) throws Exception {
        s = getServerSocket(port);
        while (true) {
            try {
                Socket us = s.accept();
                Handler(us);
            } catch(IOException e) {
                System.err.println(e);
                return;
            }

        }
    }

    /** Handler() handles one conversation with a Web client.
 * This is the only part of the program that "knows" HTTP.
 */
    public void Handler(Socket s) {
        BufferedReader is;    // inputStream, from Viewer
        PrintWriter os;        // outputStream, to Viewer
        String request;        // what Viewer sends us.
        try {
            String from = s.getInetAddress().toString();
            System.out.println("Accepted connection from " + from);
            is = new BufferedReader(new InputStreamReader(s.getInputStream()));
            request = is.readLine();
            System.out.println("Request: " + request);

            os = new PrintWriter(s.getOutputStream(), true);
            os.print("HTTP/1.0 200 Here is your data" + CRLF);
            os.print("Content-type: text/html" + CRLF);
            os.print("Server-name: DarwinSys NULL Java WebServer 0" + CRLF);
            String reply1 = "<html><head>" +
                "<title>Wrong System Reached</title></head>\n" +
                "<h1>Welcome, ";
            String reply2 = ", but...</h1>\n" +
                "<p>You have reached a desktop machine " +
                "that does not run a real Web service.\n" +
                "<p>Please pick another system!</p>\n" +
                "<p>Or view <a href=\"" + VIEW_SOURCE_URL + "\">" +
                "the WebServer0 source on github</a>.</p>\n" +
                "<hr/><em>Java-based WebServer0</em><hr/>\n" +
                "</html>\n";
            os.print("Content-length: " +
                (reply1.length() + from.length() + reply2.length()) + CRLF);
            os.print(CRLF);
            os.print(reply1 + from + reply2 + CRLF);
            os.flush();
            s.close();
        } catch (IOException e) {
            System.out.println("IOException " + e);
        }
        return;
    }
}

13.7 使用 SSL 和 JSSE 保护 Web 服务器

问题

当数据在传输过程中,你希望保护网络流量免受窥视或恶意修改。

解决方案

使用 Java 安全套接字扩展 JSSE 加密你的流量。

讨论

JSSE 提供多个级别的服务,但最简单的使用方式是从SSLServerSocketFactory获取ServerSocket,而不是直接使用ServerSocket构造函数。SSL 即安全套接字层,其修订版被称为 TLS。它专门用于网络安全。要保护其他协议,你必须使用不同形式的SocketFactory

SSLServerSocketFactory返回一个设置为进行 SSL 加密的ServerSocket。Example 13-10 使用这种技术覆盖了 Recipe 13.6 中的getServerSocket()方法。如果你认为这太容易了,那你就错了!

示例 13-10. main/src/main/java/network/JSSEWebServer0
/**
 * JSSEWebServer - subclass trivial WebServer0 to make it use SSL.
 * N.B. You MUST have set up a server certificate (see the
 * accompanying book text), or you will get the dreaded
 * javax.net.ssl.SSLHandshakeException: no cipher suites in common
 * (because without it JSSE can't use any of its built-in ciphers!).
 */
public class JSSEWebServer0 extends WebServer0 {

    public static final int HTTPS = 8443;

    public static void main(String[] args) throws Exception {
        if (System.getProperty("javax.net.ssl.keyStore") == null) {
            System.err.println(
                "You must pass in a keystore via -D; see the documentation!");
            System.exit(1);
        }
        System.out.println("DarwinSys JSSE Server 0.0 starting...");
        JSSEWebServer0 w = new JSSEWebServer0();
        w.runServer(HTTPS);        // never returns!!
    }

    /** Get an HTTPS ServerSocket using JSSE.
 * @see WebServer0#getServerSocket(int)
 * @throws ClassNotFoundException the SecurityProvider can't be instantiated.
 */
    protected ServerSocket getServerSocket(int port) throws Exception {

        SSLServerSocketFactory ssf =
            (SSLServerSocketFactory)SSLServerSocketFactory.getDefault();

        return ssf.createServerSocket(port);
    }

}

这确实是你需要编写的所有 Java 代码。你必须设置 SSL 证书。为了演示目的,可以使用自签名证书;darwinsys.com/java/selfsigncert.html 中的步骤(步骤 1–4)足以满足要求。你必须告诉 JSSE 层在哪里找到你的密钥库:

java -Djavax.net.ssl.keyStore=/home/ian/.keystore -Djavax.net.ssl.
keyStorePassword=secrit JSSEWebServer0

典型的客户端浏览器对自签名证书感到怀疑(见 Figure 13-1),但如果用户确认,将接受该证书。

Figure 13-2 显示了简单的WebServer0在 HTTPS 协议下的输出(请注意右下角的挂锁)。

jcb4 1301

图 13-1. 浏览器注意事项

jcb4 1302

图 13-2. 使用加密

参见

JSSE 不仅可以加密 Web 服务器流量,而且有时被视为其最激动人心的应用程序。有关 JSSE 的更多信息,请参见Sun 网站《Java 安全》(由 Scott Oaks 编写,O’Reilly 出版)。

13.8 使用 JAX-RS 创建 REST 服务

问题

您想要通过使用提供的 Java EE/Jakarta EE API 来实现一个 RESTful 服务器。

解决方案

在提供服务的类上使用 JAX-RS 注解,并将其安装在企业应用服务器中。

讨论

该操作包括编码和配置两部分。

编码步骤包括创建一个扩展 JAX-RS Application类的类,并在提供服务的类上添加注解。

这是一个最小化的Application类示例:

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

@ApplicationPath("")
public class RestApplication extends Application {
	// Empty
}

示例 13-11 是一个类似“Hello, World”的服务类,带有使其成为服务类以及具有三个示例方法所需的注解。

示例 13-11. restdemo/src/main/java/rest/RestService.java
@Path("")
@ApplicationScoped
public class RestService {

    public RestService() {
        System.out.println("RestService.init()");
    }

    @GET @Path("/timestamp")
    @Produces(MediaType.TEXT_PLAIN)
    public String getDate() {
        return LocalDateTime.now().toString();
    }

    /** A Hello message method
 */
    @GET @Path("/greeting/{userName}")
    @Produces("text/html")
    public String doGreeting(@PathParam("userName")String userName) {
        System.out.println("RestService.greeting()");
        if (userName == null || userName.trim().length() <= 3) {
            return "Missing or too-short username";
        }
        return String.format(
            "<h1>Welcome %s</h1><p>%s, We are glad to see you back!",
            userName, userName);
    }

    /** Used to download all items */
    @GET @Path("/names")
    @Produces(MediaType.APPLICATION_JSON)
    public List<String> findTasksForUser() {
        return List.of("Robin", "Jedunkat", "Lyn", "Glen");
    }
}

现在必须部署该类。如果我们已经创建了适当的 Maven 项目结构(参见 Recipe 1.7)并提供了特定于应用服务器的 Maven 插件,并且我们的开发服务器正在运行,则可以使用类似mvn deploy的变体。在这种情况下,我已经为在 WildFly 上部署设置了这个,在rest子目录下,只需执行mvn wildfly:deploy即可编译、打包并部署应用程序到我的服务器。

如果要基于 Eclipse MicroProfile 部署 REST 服务作为微服务,您可能希望研究Quarkus 框架

一旦服务部署完成,您可以使用浏览器或简单的 GET 请求的 Telnet 客户端进行交互探索:

$ telnet localhost 8080 # output cleaned up
Escape character is '^]'.
GET /rest/timestamp HTTP/1.0
Connection: keep-alive

HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8

2019-10-16T19:54:31.42

GET /rest/greeting/Ian%20Darwin HTTP/1.0

HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8

<h1>Welcome Ian Darwin</h1><p>Ian Darwin, We are glad to see you back!

get /rest/names HTTP/1.0
Accept: Application/JSON

HTTP/1.1 200 OK
Content-Type: application/json

["Robin","Jedunkat","Lyn","Glen"]
^] (CTRL/C)
$

REST 的一个问题是缺乏官方标准来文档化服务器提供的 API 或协议(存在几个竞争的规范)。因此,编写客户端的人必须依赖服务器开发者提供的纯文档,或者通过试验发现协议。我们这里的例子足够简单,不会遇到这个问题,但想象一下一个类中有 20 或 30 个方法的情况。

Spring 框架提供了一个与此处使用的 JAX-RS API 非常相似的 API;如果您已经在使用 Spring,可能更简单使用它们的注解。

13.9 网络日志记录

问题

您的类正在服务器容器中运行,其调试输出难以获得。

解决方案

使用像 Java Logging API (JUL)、Apache Logging Services 项目的Log4j或这里展示的简单网络日志记录器。

讨论

在大多数操作系统上,从桌面客户端获取调试输出相当容易。 但是,如果要调试的程序正在像 Servlet 引擎或 EJB 服务器这样的容器中运行,那么获取调试输出可能会很困难,特别是如果容器在远程计算机上运行。 如果您的程序可以将消息发送回桌面机器上的程序以进行即时显示,那将非常方便。 不用说,使用 Java 的套接字机制做到这一点并不难。

许多日志 API 可以处理此问题:

  • 多年来,Java 一直拥有一个标准的日志 API JUL(在 Recipe 13.12 中讨论),它可以与包括 Unix syslog在内的各种日志机制通信。

  • Apache Logging Services 项目生成Log4j,用于许多需要日志记录的开源项目(请参阅 Recipe 13.11)。

  • Apache Jakart Commons Logging (JCL)。 这里没有讨论; 与其他日志 API 类似。

  • SLF4J(Java 的简单日志门面,参见 Recipe 13.10)是最新的门面,可以使用其他日志 API。

  • 并且,在这些广泛使用之前,我编写了一个小而简单的 API 来处理此类日志记录功能。 我的netlog在这里没有讨论,因为最好使用标准的日志机制之一; 如果您想挖掘它,它的代码在* javasrc * repo 的* logging *子目录中。

JDK 日志 API,Log4jSFL4J更加完整,可以写入文件; 一个OutputStreamWriter; 或远程Log4j,Unix syslog或 Windows 事件日志服务器。

从日志 API 的角度来看,正在调试的程序是客户端——即使它可能在类似 Web 服务器或应用服务器的服务器端容器中运行——因为网络客户端是发起连接的程序。 在您的桌面计算机上运行的程序是套接字的“服务器”程序,因为它等待连接的到来。

如果您希望运行任何可以从任何公共网络访问的基于网络的日志记录器,则需要更加注意安全问题。 一种常见的攻击形式是简单的拒绝服务(DoS),在此期间,攻击者会向您的服务器发起大量连接以减慢其速度。 例如,如果您正在将日志写入磁盘,攻击者可以通过发送大量垃圾邮件填满您的磁盘。 在常见用法中,您的日志监听器将位于防火墙后面,不可从外部访问; 但如果不是这种情况,请注意 DoS 攻击。

13.10 设置 SLF4J

问题

您希望使用一个日志 API,可以使用任何其他日志 API,例如,这样您的代码可以在其他项目中使用,而无需切换日志 API。

解决方案

使用 SLF4J:从LoggerFactory获取Logger,并使用其各种方法进行日志记录。

讨论

使用 SLF4J 仅需要一个 JAR 文件进行编译,slf4j-api-1.x.y.jar(其中 xy 将随时间变化)。要实际获得日志输出,您需要将多个实现 JAR 添加到运行时 CLASSPATH,其中最简单的是 slf4j-simple-1.x.y.jar(其中 xy 应该在这两个文件之间匹配)。

一旦将这些 JAR 文件添加到构建脚本或您的 CLASSPATH 上,您可以通过调用 LoggerFactory.getLogger() 来获取 Logger,传递类或包的字符串名称或当前 Class 引用。然后调用记录器的记录方法。一个简单的示例在 Example 13-12 中。

示例 13-12. main/src/main/java/logging/Slf4jDemo.java
public class Slf4jDemo {

    final static Logger theLogger =
            LoggerFactory.getLogger(Slf4jDemo.class);

    public static void main(String[] args) {

        Object o = new Object();
        theLogger.info("I created this object: " + o);

    }
}

有各种方法用于记录不同严重程度的信息,这些方法显示在 Table 13-1 中。

Table 13-1. SLF4j 记录方法

名称 含义
trace 冗长的调试信息(默认禁用)
debug 冗长的调试信息
info 低级别信息消息
warn 可能的错误
error 严重错误

SLF4j 相对于大多数其他日志记录 API 的优势之一是避免了死字符串反模式。在使用许多其他记录器 API 时,您可能会发现以下代码:

logger.log("The value is " + object + "; this is not good");

这可能会导致性能问题,因为隐式调用了对象的 toString(),并且执行了两次字符串连接,甚至在我们知道日志记录器是否要使用它们之前!如果这是重复调用的代码,可能会浪费大量开销。

这导致其他日志包提供了代码保护功能,基于能够非常快速地查找日志记录器是否启用的记录器方法,导致出现以下代码:

if (logger.isEnabled()) {
	logger.log("The value is " + object + "; this is not good");
}

这解决了性能问题,但使代码混乱!SLF4J 的解决方案是使用类似于(但不完全兼容)Java 的 MessageFormat 机制,如 Example 13-13 中所示。

示例 13-13. main/src/main/java/logging/Slf4jDemo2.java
public class Slf4jDemo2 {

    final static Logger theLogger = LoggerFactory.getLogger(Slf4jDemo2.class);

    public static void main(String[] args) {

        try {
            Person p = new Person();
            // populate person's fields here...
            theLogger.info("I created an object {}", p);

            if (p != null) {    // bogus, just to show logging
                throw new IllegalArgumentException("Just testing");
            }
        } catch (Exception ex) {
            theLogger.error("Caught Exception: " + ex, ex);
        }
    }
}

虽然这并不演示网络日志记录,但可以与 Log4j 或 JUL(Java Util Logging,JDK 的标准部分)等日志记录实现一起轻松实现,这些实现允许您提供可配置的日志记录。下一个配方中描述了 Log4j

参见

SLF4J 网站包含一个手册,讨论了各种 CLASSPATH 选项。还有一些 Maven artifact提供了各种选项。

13.11 使用 Log4j 进行网络日志记录

问题

您希望使用 Log4j 写入日志文件消息。

解决方案

获取 Logger 并使用其 log() 方法或便利方法。通过更改属性文件来控制日志记录。使用 org.apache.logging.log4j.net 包使其基于网络。

讨论

警告

本文档描述了 Log4j API 的第 2 版。在第 1 版和第 2 版之间,包名称、文件名以及用于获取日志记录器的方法都有所变化。如果您看到使用例如 Logger.getLogger("class name") 的代码,则该代码是针对旧 API 编写的,该 API 不再维护(Log4j 网站将 Log4j 1.2 及其 2.12 以下版本称为“遗留版本”;我们在本文档中使用的是 2.13 版本)。对于针对 1.x API 编写的代码,提供了相当大的兼容性;参见 https://logging.apache.org/log4j/2.x/manual/compatibility.html

使用 Log4j 进行日志记录简单、方便且灵活。您需要从静态方法 LogManager.getLogger() 获取一个 Logger 对象,Logger 具有公共 void 方法(debug()info()warn()error()fatal()),每个方法接受一个要记录的 Object(和一个可选的 Throwable)。与 System.out.println() 类似,如果传入的不是 String,将调用其 toString() 方法。还包括一个通用的日志记录方法:

public void log(Level level, Object message);

Level 类在 Log4j 2 API 中定义。标准级别依次为 DEBUG < INFO < WARN < ERROR < FATAL。即,调试消息被认为是最不重要的,而致命消息则是最重要的。每个 Logger 都有一个与其关联的级别;级别低于 Logger 的消息将被静默丢弃。

一个简单的应用程序可以使用以下几条语句记录消息:

public class Log4JDemo {

    private static Logger myLogger = LogManager.getLogger();

    public static void main(String[] args) {

        Object o = new Object();
        myLogger.info("I created an object: " + o);

    }
}

如果您在没有 log4j2.properties 文件的情况下编译并运行此程序,则不会生成任何日志输出(请参阅源文件夹中的 log4j2demos 脚本)。我们需要创建一个默认名称为 log4j2.properties 的配置文件。您也可以通过系统属性提供日志文件名:-Dlog4j​.configurationFile=URL

提示

Log4j 配置非常灵活,因此也非常复杂。甚至他们自己的文档承认:“试图在不理解[日志架构]的情况下配置 Log4j 将导致沮丧。”查看此Apache 网站,获取有关日志配置文件位置和格式的详细信息

每个 Logger 都有一个 Level 来指定要写入的消息级别。它还将有一个 Appender,它是写出消息的代码。ConsoleAppender 当然写入到 System.out;其他记录器写入到文件、操作系统级别记录器等等。一个简单的配置文件看起来像这样:

# Log4J2 properties file for the logger demo programs.
# tag::generic[] # Ensure file gets copied for Java Cookbook

# WARNING - log4j2.properties must be on your CLASSPATH,
# not necessarily in your source directory.

# The configuration file for Version 2 is different from V1!

rootLogger.level = info
rootLogger.appenderRef.stdout.ref = STDOUT

appender.console.type = Console
appender.console.name = STDOUT
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %m%n
appender.console.filter.threshold.type = ThresholdFilter
appender.console.filter.threshold.level = debug

此文件将根记录器的级别设置为DEBUG,这会导致它写入所有消息。配置文件还设置了一个名为APPENDER1的附加器,该附加器在接下来的几行上进行了配置。请注意,我不必引用com.darwinsys Logger。因为每个Logger都继承自根记录器,所以一个简单的应用程序只需要配置根记录器。属性文件也可以是 XML 文档,或者您可以编写自己的配置解析器(几乎没有人这样做)。

警告

如果找不到日志配置文件,则默认的根记录器将根记录器默认为Level.ERROR,因此您将看不到ERROR级别以下的任何输出。

配置文件就位后,演示效果更好。运行此程序(使用脚本中所做的适当的CLASSPATH)会产生以下输出:

$ java Log4j2Demo
I created an object: java.lang.Object@477b4cdf
$

日志记录的常见用法是记录捕获的Exception,如示例 13-14 所示。

示例 13-14。main/src/main/java/Log4JDemo2.java(Log4j—捕获和记录)
public class Log4JDemo2 {

    private static Logger myLogger = LogManager.getLogger();

    public static void main(String[] args) {

        try {
            Object o = new Object();
            myLogger.info("I created an object: " + o);
            if (o != null) {    // bogus, just to show logging
                throw new IllegalArgumentException("Just testing");
            }
        } catch (Exception ex) {
            myLogger.error("Caught Exception: " + ex, ex);
        }
    }
}

运行时,Log4JDemo2产生预期的输出:

$ java Log4JDemo2
I created an object: java.lang.Object@477b4cdf
Caught Exception: java.lang.IllegalArgumentException: Just testing
java.lang.IllegalArgumentException: Just testing
	at logging.Log4JDemo2.main(Log4JDemo2.java:17) [classes/:?]
$

Log4j 2 的灵活性很大程度上来自于其使用外部配置文件;您可以在不重新编译应用程序的情况下启用或禁用日志记录。消除大部分日志记录的属性文件可能包含以下条目:

rootLogger.level = fatal

只打印致命错误消息;所有比它低的级别都被忽略。

要从客户端记录到远程机器上的服务器,可以使用SocketAppender。还有一个SmtpAppender通过电子邮件发送紧急通知。有关所有受支持的附加器的详细信息,请参阅https://logging.apache.org/log4j/2.x/manual/appenders.html。这是log4j2-network.properties,配置文件的基于套接字的网络版本:

# Log4J2 properties file for the NETWORKED logger demo programs.
# tag::generic[] # Ensure file gets copied for Java Cookbook

# WARNING - log4j2.properties must be on your CLASSPATH,
# not necessarily in your source directory.

# The configuration file for Version 2 is different from V1!

rootLogger.level = info
rootLogger.appenderRef.stdout.ref = STDOUT

appender.console.type = Socket
appender.console.name = STDOUT
appender.console.host = localhost
appender.console.port = 6666
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = %m%n
appender.console.filter.threshold.type = ThresholdFilter
appender.console.filter.threshold.level = debug

此文件通过netdemos脚本中的 Java 系统属性传递给演示程序:

build=../../../../target/classes
log4j2_jar=\
${HOME}/.m2/repository/org/apache/logging/log4j/log4j-api/2.13.0/log4j-api-2.13.0.jar:\
${HOME}/.m2/repository/org/apache/logging/log4j/log4j-core/2.13.0/log4j-core-2.13.0.jar

echo "==> Log4JDemo"
java -Dlog4j.configurationFile=log4j2-network.properties \
	-classpath ".:${build}:${log4j2_jar}" logging.Log4JDemo

echo "==> Log4JDemo2"
java -Dlog4j.configurationFile=log4j2-network.properties \
	-classpath ".:${build}:${log4j2_jar}" logging.Log4JDemo2

运行时使用log4j2-network.properties文件,你需要在另一端安排一个监听器。在 Unix 系统上,nc(或netcat)程序可以正常工作:

$ nc -kl 6666
I created an object: java.lang.Object@37ceb1df
I created an object: java.lang.Object@37ceb1df
Caught Exception: java.lang.IllegalArgumentException: Just testing
java.lang.IllegalArgumentException: Just testing
	at logging.Log4JDemo2.main(Log4JDemo2.java:17) [classes/:?]
^C
$

Netcat选项-l表示监听编号端口;-k告诉它继续监听,也就是说,当客户端关闭连接时重新打开连接,就像每个演示程序退出时一样发生的情况。

某些日志调用存在性能问题。考虑一些昂贵的操作,如toString()以及传递给经常使用的代码中的Log.info()的几个字符串连接。如果这些操作在更高的日志记录级别下进入生产环境,则将完成所有工作,但产生的字符串将永远不会被使用。在旧的 API 中,我们通常使用“代码保护”方法,如“isLoggerEnabled(Level)”来确定是否值得创建字符串。现在,首选的方法是在 Lambda 表达式中创建字符串(见第九章)。所有的日志方法都有一个接受Supplier参数的重载(示例 13-15](#javacook-network-SECT8-log4jlambda))。

示例 13-15. main/src/main/java/logging/Log4J2Lambda.java
public class Log4JLambda {

    private static Logger myLogger = LogManager.getLogger();

    public static void main(String[] args) {

        Person customer = getPerson();
        myLogger.info( () -> String.format(
            "Value %d from Customer %s", customer.value, customer) );

    }

这样,字符串操作仅在需要时执行:如果日志记录器以INFO级别运行,则调用Supplier,否则将不执行昂贵的操作。

当作为log4j2demos脚本的一部分运行时,会打印:

Value 42 from Customer Customer[Robin]

有关 Log4j 的更多信息,请访问其主网站。Log4j 2 是 Apache 软件基金会许可下的免费软件。

13.12 使用 java.util.logging 进行网络日志记录

问题

你希望使用 Java 日志机制编写日志消息。

解决方案

获取一个Logger,并用它来记录您的消息和/或异常。

讨论

Java 日志 API(包java.util.logging)类似于并明显受到 Log4j 包的启发。通过使用描述性字符串调用静态Logger.getLogger(),可以获取Logger对象。然后,您可以使用实例方法写入日志;这些方法包括以下内容:

public void log(java.util.logging.LogRecord);
public void log(java.util.logging.Level,String);
// and a variety of overloaded log(  ) methods
public void logp(java.util.logging.Level,String,String,String);
public void logrb(java.util.logging.Level,String,String,String,String);

// Convenience routines for tracing program flow
public void entering(String,String);
public void entering(String,String,Object);
public void entering(String,String,Object[]);
public void exiting(String,String);
public void exiting(String,String,Object);
public void throwing(String,String,Throwable);

// Convenience routines for log(  ) with a given level
public void severe(String);
public void warning(String);
public void info(String);
public void config(String);
public void fine(String);
public void finer(String);
public void finest(String);

与 Log4j 类似,每个Logger对象都有一个指定的日志级别,低于该级别的消息将被静默丢弃:

public void setLevel(java.util.logging.Level);
public java.util.logging.Level getLevel(  );
public boolean isLoggable(java.util.logging.Level);

与 Log4j 一样,对象处理日志的写入。每个日志记录器都有一个Handler

public synchronized void addHandler(java.util.logging.Handler);
public synchronized void removeHandler(java.util.logging.Handler);
public synchronized java.util.logging.Handler[] getHandlers(  );

每个Handler都有一个Formatter,用于格式化LogRecord以便显示。通过提供自己的Formatter,可以更好地控制日志中传递信息的格式。

与 Log4j 不同,Java SE 日志机制具有默认配置,因此示例 13-16 是一个最小的日志示例程序。

示例 13-16. main/src/main/java/logging/JulLogDemo.java
public class JulLogDemo {
    public static void main(String[] args) {

        Logger myLogger = Logger.getLogger("com.darwinsys");

        Object o = new Object();
        myLogger.info("I created an object: " + o);
    }
}

运行它将打印以下内容:

$ juldemos
Jan 31, 2020 1:03:27 PM logging.JulLogDemo main
INFO: I created an object: java.lang.Object@5ca881b5
$ 

与 Log4j 一样,其中一个常见用途是记录捕获的异常;此代码位于示例 13-17 中。

示例 13-17. main/src/main/java/logging/JulLogDemo2.java(捕获并记录异常)
public class JulLogDemo2 {
    public static void main(String[] args) {

        System.setProperty("java.util.logging.config.file",
            "logging/logging.properties");

        Logger logger = Logger.getLogger("com.darwinsys");

        try {
            Object o = new Object();
            logger.info("I created an object: " + o);
            if (o != null) {    // bogus, just to show logging
                throw new IllegalArgumentException("Just testing");
            }
        } catch (Exception t) {
            // All-in-one call:
            logger.log(Level.SEVERE, "Caught Exception", t);
            // Alternate: Long form, more control.
            // LogRecord msg = new LogRecord(Level.SEVERE, "Caught exception");
            // msg.setThrown(t);
            // logger.log(msg);
        }
    }
}

与 Log4j 类似,java.util.logging接受 Lambda 表达式(自 Java 8 起);请参阅示例 13-18。

示例 13-18. main/src/main/java/logging/JulLambdaDemo.java
/** Demonstrate how Java 8 Lambdas avoid extraneous object creation
 * @author Ian Darwin
 */
public class JulLambdaDemo {
    public static void main(String[] args) {

        Logger myLogger = Logger.getLogger("com.darwinsys.jullambda");

        Object o = new Helper();

        // If you change the log call from finest to info,
        // you see both the systrace from the toString,
        // and the logging output. As it is here,
        // you don't see either, so the toString() is not called!
        myLogger.finest(() -> "I created this object: " + o);
    }

    static class Helper {
        public String toString() {
            System.out.println("JulLambdaDemo.Helper.toString()");
            return "failure!";
        }
    }
}

参见

本章节主题的一个很好的综合参考是《Java 网络编程》(http://oreil.ly/java-network-prgamming),作者是 Elliotte Harold。

任何网络机制的服务器端都极其敏感于安全问题。一个配置错误或编写不佳的服务器程序很容易 compromise 整个网络的安全性!关于网络安全的许多书籍中,两本书显著:Firewalls and Internet Security,作者是 William R. Cheswick 等人(Addison-Wesley),以及系列书籍中标题为Hacking Exposed的第一本,作者是 Stuart McClure 等人(McGraw-Hill)。

这完成了我对使用套接字的服务器端 Java 的讨论。聊天服务器可以使用多种技术来实现,例如 RMI(远程方法调用),HTTP Web 服务,JMS(Java 消息服务),以及处理存储转发消息处理的 Java 企业 API。这超出了本书的范围,但在源代码分发的chat文件夹中有一个 RMI 聊天服务器的示例,还有一个 JMS 聊天服务器的示例在Java 消息服务中,作者是 Mark Richards 等人(O'Reilly)。

¹ 当然,你可能不能随意为自己的服务选择任何端口号。某些众所周知的端口号专门保留用于特定服务,并在你的services文件中列出,例如 Secure Shell 的 22 端口和 SMTP 的 25 端口。此外,在基于服务器的操作系统上,低于 1024 的端口被视为特权端口,需要 root 或管理员权限来创建。这是早期的安全机制;今天,随着无数单用户桌面连接到互联网,这种限制提供的实际安全性已经很小,但限制仍然存在。

² 数字设备被康柏吸收,随后被惠普吸收,但名称仍然是de,因为负责命名这类东西的工程师们并不关心企业并购。

³ 有一些限制影响着你可以拥有的线程数量,这只影响非常大型的企业级服务器。你不能期望在标准的 Java 运行时中运行成千上万的线程。对于大型高性能服务器,你可能希望使用本地代码(参见 Recipe 18.6)使用select()poll()

第十四章:处理 JSON 数据

14.0 简介

JSON,或 JavaScript 对象表示法,具有以下特点:

  • 一个简单、轻量级的数据交换格式。

  • 一个比 XML 更简单、更轻的替代方案。

  • 使用println()或几个 API 之一轻松生成。

  • 在所有网页浏览器中直接被 JavaScript 解析器识别。

  • 支持各种常见语言的附加框架(Java、C/C++、Perl、Ruby、Python、Lua、Erlang、Haskell 等等);在主页上列出了一个包括 Java 二十多个解析器的非常长的支持语言列表。

一个简单的 JSON 消息可能是这样的:

json/src/main/resources/json/softwareinfo.json/

{
  "name": "robinparse",
  "version": "1.2.3",
  "description": "Another Parser for JSON",
  "className": "RobinParse",
  "contributors": [
        "Robin Smythe",
        "Jon Jenz",
        "Jan Ardann"
    ]
}

如您所见,语法简单、可嵌套且适合人类检查。

JSON 主页提供了 JSON 语法的简明总结。有两种结构:JSON 对象(映射)和 JSON 数组(列表)。JSON 对象是一组名称和值对,可以表示为java.util.Map Java 对象的属性。例如,2019 年 4 月 1 日的LocalDate对象的字段可以表示为:

{
	"year": 2019,
	"month": 4,
	"day" : 1
}

JSON 数组是有序列表,在 Java 中表示为数组或java.util.List。两个日期的列表可能如下所示:

{
	[{
		"year": 2019,
		"month": 4,
		"day" : 1
	},{
		"year": 2019,
		"month": 5,
		"day" : 15
	}]
}

JSON 是自由格式的,因此前述内容也可以写成以下形式,虽然会失去人类可读性,但不会失去信息或功能性:

{[{"year":2019,"month":4,"day":1},{"year":2019,"month":5,"day":15}]}

我相信已经为 JSON 编写了数百个解析器。在 Java 的世界中,一些我能想到的包括以下几个:

stringtree.org

非常小且轻量级

json.org parser

因其免费且具有良好的域名而广泛使用

jackson.org parser

因其非常强大而广泛使用,与 Spring Framework、JBoss RESTEasy 和 Wildfly 一起使用

javax.json

Oracle 官方但目前仅限 EE 标准

本章展示了使用前面列出的各种 API 处理 JSON 数据的几种方法。官方的javax.jsonAPI 只包含在 Java EE 中,而不包含在 Java SE 中,因此在客户端很少见到使用。这个 API 使用了与org.jsonAPI 一些相同的名称,但不足以被认为是兼容的。

因为这是一本面向客户端 Java 开发者的书籍,因此将不会讨论直接在服务器生成的基于浏览器的 JavaScript 中处理 JSON 的能力,尽管这在构建企业应用程序时非常有用。

14.1 直接生成 JSON

问题

你想生成 JSON 而不需要使用 API。

解决方案

获取您想要的数据,并根据需要使用println()String.format()

讨论

如果你细心的话,你可以自己生成 JSON 数据。对于极为简单的情况,你可以使用PrintWriter.println()或者String.format()。然而,对于大量数据,通常最好使用其中一个 API。

此代码打印了LocalTime对象的年份、月份和日期(请参阅 Recipe 6.1)。 一些 JSON 格式化被委托给 toJson() 方法:

/**
 * Convert an object to JSON, not using any JSON API.
 * BAD IDEA - should use an API!
 */
public class LocalDateToJsonManually {

    private static final String OPEN = "{";
    private static final String CLOSE = "}";

    public static void main(String[] args) {
        LocalDate dNow = LocalDate.now();
        System.out.println(toJson(dNow));
    }

    public static String toJson(LocalDate dNow) {
        StringBuilder sb = new StringBuilder();
        sb.append(OPEN).append("\n");
        sb.append(jsonize("year", dNow.getYear()));
        sb.append(jsonize("month", dNow.getMonth()));
        sb.append(jsonize("day", dNow.getDayOfMonth()));
        sb.append(CLOSE).append("\n");
        return sb.toString();
    }

    public static String jsonize(String key, Object value) {
        return String.format("\"%s\": \"%s\",\n", key, value);
    }
}

当然,这是一个极端琐碎的例子。 对于任何更复杂的情况,或者对于必须解析 JSON 对象的常见情况,使用其中一个框架将更容易使您的神经放松。

14.2 使用 Jackson 解析和写入 JSON

问题

您希望使用完整功能的 JSON API 读取和/或写入 JSON。

解决方案

使用 Jackson,完整的 JSON API。

讨论

Jackson 提供了许多工作方式。 对于简单情况,可以将 POJO(Plain Old Java Objects)几乎自动转换为/从 JSON,如 Example 14-1 所示。

示例 14-1. json/src/main/java/json/ReadWriteJackson.java(使用 Jackson 读写 POJOs)
public class ReadWriteJackson {

    public static void main(String[] args) throws IOException {
        ObjectMapper mapper = new ObjectMapper();                ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)

        String jsonInput =                                       ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
                "{\"id\":0,\"firstName\":\"Robin\",\"lastName\":\"Wilson\"}";
        Person q = mapper.readValue(jsonInput, Person.class);
        System.out.println("Read and parsed Person from JSON: " + q);

        Person p = new Person("Roger", "Rabbit");                ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
        System.out.print("Person object " + p +" as JSON = ");
        mapper.writeValue(System.out, p);
    }
}

1

创建一个 Jackson ObjectMapper,可以将 POJOs 映射到/从 JSON。

2

将字符串 jsonInput 映射为 Person 对象,一次调用 readValue()

3

使用一次 writeValue() 调用将 Person 对象 p 转换为 JSON。

运行此示例会产生以下输出:

Read and parsed Person from JSON: Robin Wilson
Person object Roger Rabbit as JSON = {"id":0,"firstName":"Roger",
	"lastName":"Rabbit","name":"Roger Rabbit"}

作为另一个例子,这段代码读取了打开本章的示例文件(碰巧是一个 JSON 解析器的描述)。 请注意,贡献者数组的声明为 List<String>

public class SoftwareParseJackson {
    final static String FILE_NAME = "/json/softwareinfo.json";

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper(); ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)

        InputStream jsonInput =
            SoftwareParseJackson.class.getResourceAsStream(FILE_NAME);
        if (jsonInput == null) {
            throw new NullPointerException("can't find " + FILE_NAME);
        }
        SoftwareInfo sware = mapper.readValue(jsonInput, SoftwareInfo.class);
        System.out.println(sware);
    }

}

1

ObjectMapper 执行 JSON 输入的实际解析。

运行此示例会产生以下输出:

Software: robinparse (1.2.3) by [Robin Smythe, Jon Jenz, Jan Ardann]

当然,有些情况下映射会变得更加复杂; 为此,Jackson 提供了一组注解来控制映射。 但默认映射非常不错!

Jackson 还提供了一个流式 API;请参考网站了解详情。

14.3 使用 org.json 解析和写入 JSON

问题

您希望使用中等规模、广泛使用的 JSON API 读取/写入 JSON。

解决方案

考虑使用 org.json API,也称为 JSON-Java; 它被广泛使用,也用于 Android。

讨论

org.json 包不如 Jackson 高级,也不如高级;它让你以 JSON 抽象层次而不是 Java 代码层次思考和工作。 例如,这是从本章开头读取软件描述的 org.json 版本:

public class SoftwareParseOrgJson {
    final static String FILE_NAME = "/json/softwareinfo.json";

    public static void main(String[] args) throws Exception {

        InputStream jsonInput =
            SoftwareParseOrgJson.class.getResourceAsStream(FILE_NAME);
        if (jsonInput == null) {
            throw new NullPointerException("can't find" + FILE_NAME);
        }
        JSONObject obj = new JSONObject(new JSONTokener(jsonInput));      ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        System.out.println("Software Name: " + obj.getString("name"));    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        System.out.println("Version: " + obj.getString("version"));
        System.out.println("Description: " + obj.getString("description"));
        System.out.println("Class: " + obj.getString("className"));
        JSONArray contribs = obj.getJSONArray("contributors");            ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
        for (int i = 0; i < contribs.length(); i++) {                     ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
            System.out.println("Contributor Name: " + contribs.get(i));
        }
    }

}

1

从输入创建 JSONObject

2

检索单个 String 字段。

3

检索贡献者名称的 JSONArray

4

org.json.JSONArray 不实现 Iterable,因此无法使用 forEach 循环。

运行它会产生预期的输出:

Software Name: robinparse
Version: 1.2.3
Description: Another Parser for JSON
Class: RobinParse
Contributor Name: Robin Smythe
Contributor Name: Jon Jenz
Contributor Name: Jan Ardann

JSONObjectJSONArray 使用它们的 toString() 方法生成(正确格式化的)JSON 字符串,如下所示:

public class WriteOrgJson {
    public static void main(String[] args) {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("Name", "robinParse").        ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
            put("Version", "1.2.3").
            put("Class", "RobinParse");
        String printable = jsonObject.toString();    ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        System.out.println(printable);
    }
}

1

它提供了流畅 API,允许方法调用的链式操作。

2

toString() 将转换为文本 JSON 表示形式。

运行这个程序会产生以下结果:

{"Name":"robinParse","Class":"RobinParse","Version":"1.2.3"}

参见

org.json 库的代码,包括其 javadoc 文档,都可以在 https://github.com/stleary/JSON-java(在名称上使用 JSON-java 以区分它与其他包)在线查看。

14.4 使用 JSON-B 解析和写入 JSON

问题

您希望使用中型、符合标准的 JSON API 进行读写 JSON。

解决方案

考虑使用 JSON-B,这是新的 Java 标准(JSR-367)。

讨论

JSON-B(JSON Binding)API 旨在简化读写 Java POJOs。这在代码示例 Example 14-2 中得到了清晰的展示。

示例 14-2. json/src/main/java/json/ReadWriteJsonB.java(使用 JSON-B 读写 JSON)
public class ReadWriteJsonB {

    public static void main(String[] args) throws IOException {

        Jsonb jsonb = JsonbBuilder.create();            ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)

        // Read
        String jsonInput =                              ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
                "{\"id\":0,\"firstName\":\"Robin\",\"lastName\":\"Williams\"}";
        Person rw = jsonb.fromJson(jsonInput, Person.class);
        System.out.println(rw);

        String result = jsonb.toJson(rw);                ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
        System.out.println(result);
    }
}

1

创建一个 Jsonb 对象,这是你使用 JSON-B 服务的入口。

2

获取一个 JSON 字符串,并使用 jsonb.fromJson() 将其转换为 Java 对象。

3

使用反向 jsonb.toJson()Person 对象转换回 JSON 字符串。

注意方法命名合理,不需要在 Java 实体类上使用任何注解即可实现功能。然而,我们有一个 API 允许我们自定义。例如,fullName 属性只是一个便利,用于在名字和姓氏之间添加一个空格。因此,它完全是多余的,不需要在 JSON 网络流上传输。然而,运行程序会产生以下输出:

{"firstName":"Robin","fullName":"Robin Williams","id":0,"lastName":"Williams"}

我们只需在 Person 类的 getFullName() 访问器中添加 @JsonbTransient 注解即可消除冗余;现在运行程序会产生较小的输出:

{"firstName":"Robin","id":0,"lastName":"Williams"}

参见

与大多数其他 JSON API 一样,从简单注解到编写完整自定义序列化器/反序列化器助手,都有全面的支持。请参阅 JSON-B 规范页面JSON-B 主页这个更长的在线教程

14.5 使用 JSON 指针查找 JSON 元素

问题

当你有一个 JSON 文档并且只想从中提取选定的值时。

解决方案

使用 javax.jsonJSON Pointer 实现,这是从 JSON 中提取选定元素的标准 API。

讨论

互联网标准 RFC 6901 详细说明了 JSON Pointer 的语法,这是一种独立于语言的语法,用于匹配 JSON 文档中的元素。显然受到 XML 语法 XPath 的启发,JSON Pointer 比 XPath 简单一些,因为 JSON 本身的简单性。基本上,JSON Pointer 是一个字符串,用于标识 JSON 文档中的元素(简单或数组)。javax.json 包提供了一种对象模型 API,与 Java 的 XML DOM API 类似,允许您创建不可变对象来表示对象(通过 JsonObjectBuilderJsonArrayBuilder)或通过 ReaderInputStream 从 JSON 字符串格式中读取它们。

JSON 指针以 “/” 开始(继承自 XPath),后跟我们要查找的元素或子元素的名称。假设我们扩展了示例 14-2 中的 Person 示例,以添加喜剧演员扮演的角色数组,看起来像这样:

{"firstName":"Robin","lastName":"Williams",
	"age": 63,"id":0,
	"roles":["Mork", "Mrs. Doubtfire", "Patch Adams"]}

接下来,以下 JSON 指针应生成给定的匹配:

/firstName => Robin
/age => 63
/roles => ["Mork","Mrs. Doubtfire","Patch Adams"]
/roles/1 => "Mrs. Doubtfire"

示例 14-3 中的程序演示了这一点。

示例 14-3. json/src/main/java/json/JsonPointerDemo.java
public class JsonPointerDemo {

    public static void main(String[] args) {
        String jsonPerson =
            "{\"firstName\":\"Robin\",\"lastName\":\"Williams\"," +
                "\"age\": 63," +
                "\"id\":0," +
                "\"roles\":[\"Mork\", \"Mrs. Doubtfire\", \"Patch Adams\"]}";

        System.out.println("Input: " + jsonPerson);

        JsonReader rdr =
                Json.createReader(new StringReader(jsonPerson));       ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        JsonStructure jsonStr = rdr.read();
        rdr.close();

        JsonPointer jsonPointer;
        JsonString jsonString;

        jsonPointer = Json.createPointer("/firstName");                ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        jsonString = (JsonString)jsonPointer.getValue(jsonStr);
        String firstName = jsonString.getString();
        System.out.println("/firstName => " + firstName);

        JsonNumber num =                                               ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
                (JsonNumber) Json.createPointer("/age").getValue(jsonStr);
        System.out.println("/age => " + num + "; a " + num.getClass().getName());

        jsonPointer = Json.createPointer("/roles");                    ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        JsonArray roles = (JsonArray) jsonPointer.getValue(jsonStr);
        System.out.println("/roles => " + roles);
        System.out.println("JsonArray roles.get(1) => " + roles.get(1));

        jsonPointer = Json.createPointer("/roles/1");                  ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
        jsonString = (JsonString)jsonPointer.getValue(jsonStr);
        System.out.println("/roles/1 => " + jsonString);
    }
}

1

通过使用 JsonReaderStringReader 创建 JsonStructure,这是进入此 API 的入口。

2

创建 firstName 元素的 JSON Pointer,并从元素的值获取 JsonString。如果不确定元素是否会被找到,则使用 jsonPointer.containsValue(jsonStr) 先检查,因为 getValue() 如果未找到元素将抛出异常。

3

对于 age 也是一样,但使用更流畅的语法。如果打印匹配 /age 的类名称,它将报告一个特定于实现的实现类,例如 org.glassfish.json.JsonNumberImpl$JsonIntNumber。将 XML 中的年龄从 63 更改为 63.5,它将打印一个带有 BigDecimal 的类名。无论哪种方式,该对象的 toString() 将只返回数值。

4

在 JSON 文件中,roles 是一个数组。因此,使用 JSON 指针获取它应返回一个 JsonArray 对象,因此我们将其转换为该类型的引用。这类似于不可变的 List 实现,因此我们调用 get()。JSON 数组索引从零开始,与 Java 相同。

5

直接检索相同的数组元素,使用带有“/1”的模式表示数组中的编号元素。

有可能(但幸运的是不常见)JSON 元素名称包含特殊字符,如斜杠。大多数字符对于 JSON Pointer 不是特殊的,但要匹配包含斜杠(*/)的名称,斜杠必须输入为 1*,而因为这使得波浪号(*)变成特殊字符,所以波浪号必须输入为 ~0。因此,如果 Person JSON 文件有一个像 "ft/pt/~" 的元素,你需要使用 Json.createPointer("/ft~1pt~1~0") 来查找它。

参见

JSON Pointer API 提供了额外的方法,让你可以修改数值和添加/移除元素。官方主页为 javax.json,其中包括 JSON Pointer,位于 jakarta.eejavax.json 的 javadoc 可从该页面链接获取。

概要

Java 有很多 API。Jackson 是最大最强大的;org.json、javax.json 和 JSON-B 处于中间地带,StringTree(我没有给出示例因为它没有 Maven Artifact)最小。要查看这些及其他 JSON API 的列表,请参阅 https://www.json.org/json-en.html,并滚动到语法总结之后。

第十五章:包和打包

第 15.0 章:引言

Java 语言的一个更好的方面之一是它定义了一个非常清晰的打包机制,用于分类和管理其庞大的 API。与大多数其他语言相比,这些语言中的符号可能在 C 库本身或其他几十个库中找到,而没有明确定义的命名约定。¹ API 包括一个或多个包,包括类,类包括方法和字段。任何人都可以创建一个包,但有一个重要的限制:你和我不能创建以四个字母 java 开头的包。以 java.javax. 命名的包是由 Oracle 的 Java 开发人员使用的,受 Java Community Process (JCP) 管理。当 Java 刚推出时,大约有十几个包,这个结构至今仍然存在,尽管它的大小已经增加了四倍;其中一些包显示在 Table 15-1 中。

Table 15-1. Java 包基本结构

名称 功能
java.awt 图形用户界面
java.io 读写
java.lang 内置类 (String 等)
java.lang.annotation 注解处理的库支持
java.math 数学库
java.net 网络 (套接字)
java.nio “New” I/O (不再是新的):基于通道的 I/O
java.sql Java 数据库连接
java.text 处理和格式化/解析日期、数字、消息
java.time Java 8:现代日期/时间 API (JSR-311)
java.util 实用程序 (集合、日期)
java.util.regex 正则表达式
javax.naming JNDI
javax.print 打印支持
javax.script Java 6:脚本引擎支持
javax.swing 现代图形用户界面

多年来已经添加了许多包,但最初的结构在相当长的时间内经受住了考验。在本章中,我将向您展示如何创建和记录自己的包,然后讨论与在各种平台上以各种方式部署您的包相关的一些问题。

本章还涵盖了包的更传统的含义,即为他人使用创建程序包。这包括了在 Java 9 中引入的 Java 平台模块系统 (JPMS)。我们还涵盖了 jlink,这是一个用于创建仅包含您实际使用的 JDK 部分的迷你 Java 分发工具。我们还没有涵盖用于打包应用程序的 jpackage 工具,因为它尚未包含在 JDK 中;它可能会在 Java 14 或 15 中推出。

15.1 创建一个包

问题

您希望能够导入类和/或组织您的类,因此您希望创建自己的包。

解决方案

在每个文件的开头放置一个 package 语句,并使用 -d 或构建工具或 IDE 重新编译。

讨论

package语句必须是你的 Java 源文件中非注释语句的第一个语句,甚至必须在import语句之前,并且必须给出包的完整名称。包名预期以你的域名反向开始;例如,我的互联网域名是 darwinsys.com,因此我的大多数包都以com.darwinsys和项目名称开头。本书中用于重复使用的实用类位于 Recipe 1.6 中列出的com.darwinsys包中,并且每个源文件都以这样的语句开头:

package com.darwinsys.util;

JavaSrc存储库中的演示类没有遵循这种模式;它们位于与它们所在章节或java.*包相关的命名的包中;例如,lang用于基本的 Java 内容,structure用于数据结构章节的示例(Chapter 7),threads用于线程章节(Chapter 16),等等。希望如果你在应用程序中重用它们,你会将它们放入一个“真正的”包中!

一旦你放置了包语句,请注意 Java 运行时甚至编译器都期望找到正确位置的编译后的 .class 文件(即在与完整名称对应的子目录中,这些子目录应该在你的CLASSPATH设置中)。例如,com.darwinsys.util.FileIO的类文件绝不能在我的CLASSPATH中的 FileIO.class 文件中,而必须相对于CLASSPATH中的一个目录或存档中的 com/darwinsys/util/FileIO.class。因此,如果你正在使用命令行编译器编译,习惯上(几乎是强制性的)在编译时使用 -d 命令行参数。此参数后必须跟随一个现有目录的名称(通常使用 . 表示当前目录),以指定构建目录树的位置。例如,要编译当前目录中的所有 .java 文件,并在其中创建目录路径(例如,在示例中创建 ./com/darwinsys/util),请使用以下命令:

javac -d . *.java

这将在当前目录中创建路径(例如 com/darwinsys/util/),并将类文件放入该子目录中。这使得后续的编译和创建存档变得简单,这在 Recipe 15.5 中有所涵盖。

当然,如果你使用像 Maven 这样的构建工具(见 Recipe 1.7),这将会默认正确完成(Maven),所以你不必记得一直去做它!

请注意,在所有现代 Java 环境中,不属于包的类(匿名包)不能在import语句中列出,尽管它们可以被同一包中的其他类引用。它们也不能成为 JPMS 模块的一部分。

15.2 使用 Javadoc 文档化类

问题

你听说过这个叫做代码重用的东西,并希望通过允许其他开发者使用你的类来推广它。

解决方案

使用 javadoc。写代码时编写评论。

讨论

Java 文档注释是 Java 早期伟大的发明之一。就像许多好东西一样,它并非完全由 Java 开发者发明;早期的项目如 Knuth 的文学编程已将源代码和文档结合在单个源文件中。但 Java 开发者们在正确的时间做了好事情。Javadoc 对 Java 类而言,就像 Unix 的man 页面或 Windows 应用程序的 Windows 帮助一样:这是每个人都期望找到并知道如何使用的标准格式。学习它。使用它。编写它。长命百岁(好吧,也许这并不是百分之百保证)。但是所有那些 HTML 文档,你从编写 Java 代码中学到的,JDK 的完整参考资料——你是否认为他们雇佣了数十名技术作家来制作它?不,这不是 Java 的方式。Java 的开发者们在编写代码的同时编写文档注释,并在发布时,在所有数以万计的公共类上运行 javadoc,并同时生成文档捆绑包。当你为其他开发者准备类时,你可以、应该且真的必须这样做。

你只需在 Java 源文件中放入特殊的javadoc 注释就可以使用 javadoc。这些注释类似于多行 Java 注释,但以斜杠和两个星号开始,并以正常的星号斜杠结束。Javadoc 注释必须出现在它们所文档化的类、方法或字段的定义之前;如果放在其他地方,它们将被忽略。

一系列关键字,以@符号为前缀,可以出现在特定上下文的文档注释中。其中一些包含在大括号中。截至 Java 8,这些关键字列在 Table 15-2 中。

表 15-2. Javadoc 关键字

关键字 使用
@author 作者姓名
{@code *text*} 以代码字体显示文本,不解析 HTML
@deprecated 引发过时警告
{@docroot} 指向生成文档树的根目录
@exception @throws的别名
{@inheritDoc} 从最近的超类/超接口继承文档
@link 生成指向另一个类或成员的内联链接
@linkplain @link类似,但以纯文本显示
{@literal *text*} 以不解析形式显示文本
@param *name description* 参数名称和含义(仅限方法)
@return 返回值
@see 生成到另一个类或成员的交叉引用链接
@serial 描述可序列化字段
@serialData 描述序列化形式中数据的顺序和类型
@serialField 描述可序列化字段
@since JDK 版本引入的版本(主要用于 Sun 使用)
@throws 抛出的异常类及条件
{@value [*ref*]} 显示此常量字段或另一个常量字段的值
@version 版本标识符

Example 15-1 是一个有些刻意的示例,展示了一些常见的 javadoc 关键字的使用。将其通过 javadoc 运行后,在浏览器中显示的输出如 图 15-1 所示。

Example 15-1. main/src/main/java/javadoc/JavadocDemo.java
public class JavadocDemo extends JPanel {

    private static final long serialVersionUID = 1L;

    /**
 * Construct the GUI
 * @throws java.lang.IllegalArgumentException if constructed on a Sunday.
 */
    public JavadocDemo() {
        // We create and add a pushbutton here,
        // but it doesn't do anything yet.
        Button b = new Button("Hello");
        add(b);                        // connect Button into component
        // Totally capricious example of what you should not do
        if (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) {
            throw new IllegalArgumentException("Never On A Sunday");
        }
    }

    /** paint() is an AWT Component method, called when the
 * component needs to be painted. This one just draws colored
 * boxes in the window.
 *
 * @param g A java.awt.Graphics that we use for all our
 * drawing methods.
 */
    public void paint(Graphics g) {
        int w = getSize().width, h = getSize().height;
        g.setColor(Color.YELLOW);
        g.fillRect(0, 0, w/2, h);
        g.setColor(Color.GREEN);
        g.fillRect(w/2, 0, w, h);
        g.setColor(Color.BLACK);
        g.drawString("Welcome to Java", 50, 50);
    }
}

Javadoc 对单个类的文档生成效果很好,但在处理包或多个包时效果更佳。您可以为每个包提供一个包摘要文件,该文件将被合并到生成的文件中。Javadoc 生成的文档具有深度交叉链接,就像伴随标准 JDK 的文档一样。有几个命令行选项;我通常使用 -author-version 来包含这些信息,并经常使用 -link 告诉它在哪里找到标准 JDK 以进行链接。

运行 javadoc -help 可获取完整的选项列表,或在线查看完整文档:Oracle’s website。图 15-1 展示了在运行 Example 15-1 所示示例时生成的文档的视图:

$ javadoc -author -version JavadocDemo.java

如果您在 Java 9+上运行此代码,还将包括一个完全功能的搜索框,显示在图 15-1 的右上角。这是用 JavaScript 实现的,因此应该在任何现代浏览器中都能工作。

请注意,会生成相当多的文件,并且每个生成的文件将与每个类的名称相同,扩展名为 .html。如果恰好有一个 HTML 文件记录了该类,并且您在源目录中生成了 javadoc,那么该 .html 文件将被静默地覆盖为 javadoc 输出。如果希望避免在源目录中生成文件导致混乱,可以使用 javadoc 的 -d __directorypath 选项将生成的文件放置到指定目录中。

jcb4 1501

图 15-1. Javadoc 在浏览器中打开的视图

另请参阅

Javadoc 还有许多其他命令行参数。如果文档仅供您自己使用而不会分发,可以使用 -link 选项告诉它您的标准 JDK 文档的安装位置,以便生成到标准 Java 类(如 StringObject 等)的链接。如果要分发文档,可以省略 -link 或使用 -link 与 Oracle 网站上适当 Java API 页面的 URL。查看在线工具文档以获取所有命令行选项。

javadoc 生成的输出对大多数目的都很好。可以编写自己的Doclet类来将 javadoc 程序转换为类文档验证器、Java 到其他格式(如 Java 到 RTF)的文档生成器或其他任何你喜欢的工具。这些都是实际例子;请查看随 JDK 提供的 javadoc 工具文档和示例,或者访问Oracle 的网站。访问Doclet获取一些有点过时但有用的 Doclets 和其他基于 javadoc 的工具集。

Javadoc 与 JavaHelp

Javadoc 适用于使用你的类的程序员;对于 GUI 应用程序,终端用户可能更喜欢标准的在线帮助。这就是 JavaHelp API 的角色,该 API 未在本书中涵盖,但在Creating Effective JavaHelp by Kevin Lewis (O’Reilly)中有全面解释,每个 GUI 应用程序开发者都应该阅读。JavaHelp 是另一个有用的规范,Sun 被 Oracle 收购期间有些被忽视;现在托管在java.net上,访问javahelp

15.3 超越 Javadoc:注解/元数据

问题

你想要不仅从源代码生成文档,还要生成其他代码工件。你需要标记代码以进行额外的编译器验证。

解决方案

使用 Java Annotations 或 Metadata 设施。

讨论

开源工具XDoclet的持续成功——最初用于为广受批评的 EJB2 框架生成繁琐的辅助类和部署描述符文件——导致标准 Java 中对类似机制的需求。Java Annotations 是其结果。注解 机制使用类似接口的语法,其中注解的声明和使用都使用前导符号(@)。设计者选择这种方式,是为了让人联想到“Javadoc 标签”,这是 Java 编程语言中已有的一种特定注解设施。Javadoc 只是在其@标签从未完全集成到语言中的意义上是特定的;大多数标签被编译器忽略,但@deprecated始终被编译器理解(参见 Recipe 1.9)。

注解可以在运行时通过反射 API 读取;这在 Recipe 17.10 中讨论,我还会向你展示如何定义自己的注解。注解也可以在编译后由代码生成器等工具读取(也许是你,亲爱的读者!)。

注解还会被javac在编译时读取,向编译器提供额外信息。

例如,一个常见的编码错误是在你打算覆盖方法时重载它,因为你错误地使用了错误的参数类型。考虑在Object中覆盖equals方法。如果你误写了

public boolean equals(MyClass obj) {
    ...
}

那么你已经创建了一个新的重载,很可能永远不会被调用,并且将调用Object中的默认版本。为了防止这种情况,在java.lang中包含的一个注解是Override注解。这个注解没有参数,只需简单地放置在方法调用之前,就像这样:

/**
 * AnnotationOverrideDemo - Simple demonstation of Metadata being used to
 * verify that a method does in fact override (not overload) a method
 * from the parent class. This class provides the method.
 */
abstract class Top {
    public abstract void myMethod(Object o);
}

/** Simple demonstation of Metadata being used to verify
 * that a method does in fact override (not overload) a method
 * from the parent class. This class is supposed to do the overriding,
 * but deliberately introduces an error to show how the modern compiler
 * behaves
 */
class Bottom {

    @Override
    public void myMethod(String s) {    // EXPECT COMPILE ERROR
        // Do something here...
    }
}

尝试编译此代码将导致编译器错误,指出该方法未覆盖已有方法,尽管注释指示它已覆盖;这是一个致命的编译时错误:

C:> javac AnnotationOverrideDemo.java
AnnotationOverrideDemo.java:16: method does not override a method
            from its superclass
        @Override public void myMethod(String s) {     // EXPECT COMPILE ERROR
         ^
1 error
C:> 

15.4 准备一个类作为 JavaBean

问题

您有一个希望用作 JavaBean 的类。

解决方案

确保该类符合 JavaBeans 的要求。可选地,创建一个包含该类、清单和任何辅助条目的 JAR 文件。

讨论

有些 Java 组件被称为 Beans 或 JavaBeans:

  • GUI 构建器中用于视觉组件,如本章节所述。

  • 普通的旧 Java 对象(POJOs)或用于重用的组件。

  • Java Enterprise 拥有 Enterprise JavaBeans(EJBs)、JSP JavaBeans、JSF Managed Beans 和 CDI Beans,包含用于构建企业级应用程序的功能。创建和使用 Java EE 组件比普通 JavaBeans 更复杂,我们无法在本书中覆盖它们。当您需要了解企业功能时,请参阅 Arun Gupta 的Java EE 7 Essentials

  • Spring Framework也使用术语“Beans”(或“Spring Beans”)来管理它所管理的对象。

所有这些类型的 Beans 共同拥有某些命名范例。所有公共属性应通过 get/set 访问器方法访问。对于类型为Type的属性Prop,应存在以下两种方法(注意大写):

public Type getProp( );
public void setProp(Type)

例如,所有具有文本标签的各种 AWT 和 Swing 组件都具有以下一对方法:

public String getText( );
public void setText(String newText);

这种模式的一个常见变化是,对于booleanBoolean参数,getter 方法通常称为isProp()而不是getProp()

您应该为控制 Bean 的方法(set/get 方法)使用这种 set/get 设计模式。事实上,即使在非 bean 类中,这种技术也很有用以保持一致性。本节开始列出的 API 的 Bean 容器通常使用 Java 内省(参见第十七章)来查找 set/get 方法对,并且有些使用它们来构建 bean 的属性编辑器。例如,bean-aware IDE 为所有标准类型(颜色、字体、标签等)提供编辑器。您可以补充一个BeanInfo类以提供或覆盖信息。

作为 JavaBean 可用的类的最低要求如下:

  • 类必须有一个无参数的构造函数。

  • 该类应使用设置/获取范式。

  • 该类必须实现java.io.Serializable,尽管许多容器并不强制执行此操作。

  • 根据预期的使用方式,类文件可能需要打包成 JAR 文件(参见 Recipe 15.5)。

请注意,没有required继承或implements 的 JavaBean 也被称为 POJO。大多数新的 Java 框架接受 POJO 组件,而不是(像往昔那样)要求继承(例如,Struts 1 的org.struts.Action类)或实现接口(例如,EJB2 的javax.ejb.SessionBean接口)。

下面是一个可能对 Java GUI 工具箱有用的示例 JavaBean,即LabelText小部件。它将标签和一行文本字段组合成一个单元,使得组合 GUI 应用程序更加容易。在线源目录中的演示程序设置了三个LabelText小部件,如图 15-2 所示。

jcb4 1502

图 15-2. LabelText bean

LabelText的代码显示在 Example 15-2 中。请注意,它是可序列化的,并且大多数公共方法使用了设置/获取范式。大多数公共设置/获取方法只是将任务委托给标签或文本字段中的相应方法。这个 bean 实际上并没有太多内容,但它是聚合的一个很好的例子,也是 bean 的一个很好的例子。

例 15-2. darwinsys-api/src/main/java/com/darwinsys/swingui/LabelText.java
// package com.darwinsys.swingui;
public class LabelText extends JPanel implements java.io.Serializable {

    private static final long serialVersionUID = -8343040707105763298L;
    /** The label component */
    protected JLabel theLabel;
    /** The text field component */
    protected JTextField theTextField;
    /** The font to use */
    protected Font myFont;

    /** Construct the object with no initial values.
 * To be usable as a JavaBean there must be a no-argument constructor.
 */
    public LabelText() {
        this("(LabelText)",  12);
    }

    /** Construct the object with the label and a default textfield size */
    public LabelText(String label) {
        this(label, 12);
    }

    /** Construct the object with given label and textfield size */
    public LabelText(String label, int numChars) {
        this(label, numChars, null);
    }

    /** Construct the object with given label, textfield size,
 * and "Extra" component
 * @param label The text to display
 * @param numChars The size of the text area
 * @param extra A third component such as a cancel button
 * may be null, in which case only the label and textfield exist.
 */
    public LabelText(String label, int numChars, JComponent extra) {
        super();
        setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
        theLabel = new JLabel(label);
        add(theLabel);
        theTextField = new JTextField(numChars);
        add(theTextField);
        if (extra != null) {
            add(extra);
        }
    }

    /** Get the label's horizontal alignment */
    public int getLabelAlignment() {
        return theLabel.getHorizontalAlignment();
    }

    /** Set the label's horizontal alignment */
    public void setLabelAlignment(int align) {
        theLabel.setHorizontalAlignment(align);
    }

    /** Get the text displayed in the text field */
    public String getText() {
        return theTextField.getText();
    }

    /** Set the text displayed in the text field */
    public void setText(String text) {
        theTextField.setText(text);
    }

    /** Get the text displayed in the label */
    public String getLabel() {
        return theLabel.getText();
    }

    /** Set the text displayed in the label */
    public void setLabel(String text) {
        theLabel.setText(text);
    }

    /** Set the font used in both subcomponents. */
    public void setFont(Font f) {
        // This class' constructors call to super() can trigger
        // calls to setFont() (from Swing.LookAndFeel.installColorsAndFont),
        // before we create our components, so work around this.
        if (theLabel != null)
            theLabel.setFont(f);
        if (theTextField != null)
            theTextField.setFont(f);
    }

    /** Adds the ActionListener to receive action events from the textfield */
    public void addActionListener(ActionListener l) {
        theTextField.addActionListener(l);
    }

    /** Remove an ActionListener from the textfield. */
    public void removeActionListener(ActionListener l) {
        theTextField.removeActionListener(l);
    }
}

一旦编译完成,它就可以打包成 JAR 文件。大多数构建工具(如 Maven)都会为您完成这项工作。

15.5 使用 JAR 进行归档

问题

你想从你的包(或任何其他文件集合)创建一个 Java 存档(JAR)文件。

解决方案

使用jar

讨论

jar归档程序是 Java 的标准工具,用于构建归档文件。归档文件的作用与其他一些编程语言使用的程序库相同。Java 通常从归档文件加载其标准类,您可以通过使用-verbose选项运行一个简单的“Hello, World”程序来验证这一点:

java -verbose HelloWorld

创建归档是一个简单的过程。jar工具接受几个命令行参数:最常见的是c表示创建,t表示目录,x表示提取。归档名称由-f和文件名指定。选项后跟着要归档的文件和目录,如下所示:

jar cvf /tmp/MyClasses.jar .

末尾的点很重要;它表示当前目录。这个命令将当前目录及其子目录中的所有文件创建成一个归档文件,并将其保存为/tmp/MyClasses.jar文件。

大多数 JAR 文件的应用都依赖于一个额外的文件,这个文件在真正的 JAR 文件中总是存在,称为清单manifest)。这个文件总是列出了 JAR 文件的内容及其属性;你可以在其中添加额外的信息。这些属性的形式为name: value,就像在电子邮件头部、属性文件(参见 Recipe 7.10)和其他地方使用的方式一样。一些属性是应用程序必需的,而另一些是可选的。例如,Recipe 15.6 讨论了直接从 JAR 运行主程序;这需要一个Main-Program头部。你甚至可以自己定义属性,比如以下内容:

MySillyAttribute: true
MySillynessLevel: high (5'11")

你将其存储在一个名为manifest.stub的文件中²,并使用 -m 开关将其传递给jarjar 将包含你的属性在它创建的清单文件中:

jar -cv -m manifest.stub -f /tmp/com.darwinsys.util.jar .

jar 程序和相关工具向清单添加了额外的信息,包括存档中包含的所有其他文件的列表。

提示

如果你使用像 Maven 这样的工具(参见 Recipe 1.7),它会自动从源代码项目创建一个 JAR 文件,只需输入 mvn package

15.6 从 JAR 运行程序

问题

你想要分发一个包含应用程序所有类的单个大文件,并从 JAR 中运行主程序。

解决方案

在清单中创建一个带有Main-Class:行的 JAR 文件;使用java -jar选项运行程序。

讨论

java 命令有一个 -jar 选项,告诉它从 JAR 文件中运行找到的主程序。在这种情况下,它还会从同一个 JAR 文件中找到它需要加载的类。它如何知道要运行哪个类?你必须告诉它。创建一个像这样的单行条目,注意属性字段是区分大小写的,冒号后必须跟一个空格:

Main-Class: com.somedomainhere.HelloWorld

将其放在一个名为manifest.stub的文件中,并假设你想从给定包中运行程序HelloWorld。然后,你可以使用以下命令打包你的应用并从 JAR 文件中运行它:

C:> javac HelloWorld.java
C:> jar cvmf manifest.stub hello.jar HelloWorld.class
C:> java -jar hello.jar
Hello, World of Java
C:>

现在你可以将 JAR 文件复制到任何地方并以相同的方式运行它。你不需要将其添加到你的CLASSPATH或列出主类的名称。

在支持的 GUI 平台上,你也可以通过双击 JAR 文件来启动这个应用程序。这适用于 macOS、Microsoft Windows 和许多 X Windows 桌面。

在实际生活中,你可能会用 Maven 自动化这个过程,在你的 POM 文件中会包含以下内容之一:

<project ...>
    ...
    <packaging>jar</packaging>
    ...
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.4</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addclasspath>true</addclasspath>
                            <mainClass>${main.class}</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

有了这个设置,mvn package 将会构建一个可运行的 JAR 文件。然而,如果你的类有外部依赖关系,上述步骤将不会将它们打包,当你运行它时会得到一个缺少类异常。为此,你需要使用 Maven 组件插件:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>2.6</version>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifest>
                <addDefaultImplementationEntries>true
                </addDefaultImplementationEntries>
                <mainClass>${main.class}</mainClass>
                <!-- <manifestFile>manifest.stub</manifestFile> -->
            </manifest>
            <manifestEntries>
                <Vendor-URL>http://YOURDOMAIN.com/SOME_PATH/</Vendor-URL>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

现在,调用mvn package assembly:single将生成一个带有所有依赖关系的可运行 JAR。请注意,你的 target 文件夹将包含 foo-0.0.1-SNAPSHOT.jarfoo-0.0.1-SNAPSHOT-jar-with-dependencies.jar 两者;后者是你需要的。

jpackage 工具(本章介绍中提到的)将执行与 assembly:single 类似的工作,并预计将随 Java 14 一起发布。

15.7 将 Web 层组件打包成 WAR 文件

问题

你有一些 Web 层资源,并希望将它们打包成一个单一文件以部署到服务器上。

解决方案

使用 jar 制作 Web 存档(WAR)文件。或者,如前所述,使用 Maven,其packaging=*war*

讨论

Servlet 是用于 Web 服务器的服务器端组件。它们可以打包成一个方便安装到 Web 服务器的软件包。在 Servlet API 规范中,Web 应用程序 是 HTML 和/或 JSP 页面、Servlet 和其他资源的集合。典型的目录结构可能包括以下内容:

Project Root Directory
├── README.asciidoc
├── index.html - typical web pages
|── signup.jsp - ditto
├── WEB-INF Server directory
    ├── classes - Directory for individual .class files
    ├── lib    - Directory for Jar files needed by app
    └── web.xml - web app Descriptor ("Configuration file")

一旦你以这种方式准备好文件,只需用构建工具打包它们即可。使用 Maven,使用<packaging>war</packaging>,你的目录结构可能如下所示:

Project Root Directory
├── README.asciidoc
├── pom.xml
└── src
    └── main
        ├── java
        │   └── foo
        │       └── WebTierClass.java
        └── webapp
            ├── WEB-INF
            │   ├── classes
            │   ├── lib
            │   └── web.xml
            ├── index.html
            └── signup.jsp

然后,mvn package 将编译内容,放置内容,并为你创建 WAR 文件,将其留在 target 下。Gradle 用户将使用类似的目录结构。

你也可以手动调用 jar,尽管这没什么值得推荐的。然后将生成的 WAR 文件部署到你的 Web 服务器上。有关部署步骤的详细信息,请参阅你使用的特定服务器的文档。

参见

关于签名和权限的更多信息,请参阅 Scott Oaks 的 Java 安全。有关此处提到的 JDK 工具的更多信息,请参阅你正在使用的 JDK 附带的文档。

15.8 使用 jlink 创建更小的分发包

问题

你正在将你的应用程序分发给最终用户,并希望最小化下载大小。

解决方案

将你的应用程序模块化(参见 Recipe 15.9),使用 jdeps 获取它使用的所有模块的完整列表,然后使用 jlink 创建迷你 Java,并将其分发给用户。

讨论

jlink 是在 Java 9 中引入的命令行工具,可以制作一个迷你的 Java 分发包,其中只包含你的应用程序和它使用的 JDK 类。也就是说,它省略了你的应用程序永远不会使用的数千个 JDK 类。

首先,你需要编译和打包你的 module-info 和应用程序代码。你可以使用 Maven 或 Gradle,或者直接使用 JDK 工具:

$ javac -d . src/*.java
$ jar cvf demo.jar module-info.class  demo

如果你想查看将包含的模块列表,可以选择运行jdeps工具以获取此列表:

$ jdeps --module-path . demo.jar
demo
 [file:///Users/ian/workspace/javasrc/jlink/./]
   requires mandated java.base (@11.0.2)
demo -> java.base
   demo          -> java.io        java.base
   demo          -> java.lang      java.base

一旦类文件编译完成,你可以运行jlink工具来构建一个嵌入你的演示应用的迷你 Java 分发包:

jlink --module-path . --no-header-files \
    --no-man-pages --compress=2 --strip-debug \
    --launcher rundemo=demo/demo.Hello \
    --add-modules demo --output mini-java

--launcher *name*=*module/main*参数要求jlink创建一个名为name的脚本文件来运行应用程序。

如果没有错误,您应该能够使用java命令或生成的 shell 脚本来运行它:

$ mini-java/bin/java demo.Hello
Hello, world.
$ mini-java/bin/rundemo
Hello, world.
$

您可能希望将整个迷你 Java 文件夹复制到没有常规 Java 安装的计算机上,并在那里运行它,以确保没有任何缺少的依赖项。

警告

迷你分发的概念很吸引人,但您必须考虑这些问题:

  • 这种迷你 Java 没有升级机制。它们非常适合微服务部署,因为您经常重建。但是对于发送给客户的应用程序,您必须重新生成它们,并让客户下载和重新安装(每当有安全更新时要在短时间内完成)。

  • 相对于维护此类分发所需的时间成本,磁盘空间通常不再昂贵。

因此,您必须决定这对您的应用程序是否值得。

15.9 使用 JPMS 创建模块

问题

您希望您打包的存档能够与 Java 模块系统(JPMS)平稳地工作。

解决方案

在源目录的根目录中创建一个module-info.java文件。

讨论

module-info.java文件是在 Java 9 中引入的,用于为编译器和工具提供有关库需求和提供内容的信息。请注意,这甚至不是一个有效的 Java 类文件名,因为它包含一个减号。该模块还有一组伪关键字,这些关键字仅在模块文件内部具有特殊含义。最简单的module-info如下所示:

module foo {
    // Empty
}

但就像一个没有成员的 Java 类在现实世界中不能让你走得很远一样,这个空模块文件也是如此。我们需要提供一些额外的信息。例如,在这个示例中,我将模块化我的darwinsys-api,这是一个大约有 40 个随机积累的类的集合,有时我会重复使用它们。请记住,Jigsaw(模块系统的早期名称)最初被提议作为模块化过度庞大的 JDK 本身的一种方法。大多数应用程序将需要模块java.base(它总是包含在内)。如果它们需要 AWT、Swing 或某些其他与桌面应用程序相关的类,则还需要java.desktop。因此,我在模块定义中添加了以下行:

require java.desktop

此代码还有一些带有 JUnit 注解的类,并使用了 JavaMail API,因此我们也需要它们。不过,JUnit 仅在测试时需要。虽然 Maven 提供了scope来编译、测试和运行,但模块系统则没有。因此,我们可以在 POM 文件中省略 JUnit,并将其添加到 Eclipse 中。但是,这样一来,maven test将无法工作。

不幸的是,截至本文撰写时,似乎还没有 JavaMail 的模块化版本。幸运的是,有一个称为 automatic modules 的功能,如果将一个没有声明模块的 JAR 文件放置在模块路径上,其 JAR 文件名将被用作自动生成模块的基础。因此,我们还将添加以下内容:

requires mail;

不幸的是,当我们编译时,Maven 的 Java 编译器模块会输出这样一条看起来很吓人的警告:

[WARNING] *********************************************************************
[WARNING] * Required filename-based automodules detected. Please don't publish
            this project to a public artifact repository! *
[WARNING] *********************************************************************

鉴于存在如此多的公共 Java API 库,而且它们大多依赖于其他库,我想知道:这种状态该如何结束?尽管如此,我已经注意到了这个警告,因此人们将继续使用 com.darwinsys.api 的自动模块版本,直到我找到模块化的 JavaMail 和 JUnit4 API 为止。

module-info 还列出了模块希望提供的任何包,即其公共 API。因此,我们需要一系列的导出命令:

exports com.darwinsys.calendar;
exports com.darwinsys.csv;
exports com.darwinsys.database;
...

默认情况下,导出的包不能使用反射 API 进行检查。为了允许一个模块在另一个模块上进行内省(使用反射 API),例如与 JPA 一起使用的领域模型,需要使用 opens

Java 接口的一个重要点是允许对服务进行多个实现。这在 JPMS 中通过服务特性得到支持。其中 API 在一个模块中定义为一个或多个接口,并提供多个实现,每个实现在其自己的模块中,实现模块可以使用 provides ... with 定义实现,如下所示:

requires com.foo.interfacemodule;
provides com.foo.interfacemodule.Interface with com.foo.implmodule.ImplClass;

darwinsys-api 模块的完成的 module-info 在 示例 15-3 中显示。

示例 15-3. DarwinSys-API 模块的 module-info
module com.darwinsys.api {

    requires java.desktop;
    requires java.persistence;
    requires java.prefs;
    requires java.sql;
    requires java.sql.rowset;
    requires javax.servlet.api;
    requires mail;
    requires junit;

    exports com.darwinsys.calendar;
    exports com.darwinsys.csv;
    exports com.darwinsys.database;
    exports com.darwinsys.diff;
    exports com.darwinsys.formatting;
    exports com.darwinsys.locks;
    provides com.darwinsys.locks.LockManager
        with com.darwinsys.locks.LockManagerImpl;
    exports com.darwinsys.model;
    opens com.darwinsys.model;
    // another dozen and a half packages...

}

想要使用锁接口特性的模块需要 requires com.darwinsys 并可能在代码中执行如下操作:

import java.util.ServiceLoader;
import java.util.Optional;

Optional<LockManager> opt = ServiceLoader.load(LockManager.class).findFirst();
if (!opt.isPresent()) {
    throw new RuntimeException("Could not find implementation of LockManager");
}
LockManager mgr = opt.get();

Optional 接口在 Recipe 8.6 中有描述。

参见

JPMS 是相对较新的技术,库提供者仍在学习如何正确使用它。早期的发布可以在 https://openjdk.java.net/projects/jigsaw/quick-start 找到。关于迁移到模块化的计划可以在 http://tutorials.jenkov.com/java/modules.html#migrating-to-java-9 找到。关于准备多模块 Maven 应用程序的讨论可以在 https://www.baeldung.com/maven-multi-module-project-java-jpms 找到。Sander Mak 和 Paul Bakker 的书 Java 9 Modularity: Patterns and Practices for Developing Maintainable Applications 可能是对 JPMS 最全面的处理。

¹ 这并不严格正确。至少在 Unix 中的 C 语言中,普通的包含文件和sys子目录中的文件是有区别的,而且许多结构体的名称以一个或两个字母加下划线开始,如pw_namepw_passwdpw_home。但这远不及 Java 的java.*命名约定那样一致。

² 有些人喜欢使用像MyPackage.mf这样的名称,这样就清楚这是为哪个包而准备的;扩展名.mf是任意的,但对于识别清单文件来说是一个好的约定。

第十六章:线程化 Java

16.0 介绍

我们生活在一个多活动的世界中。一个人可能一边打电话一边涂鸦或阅读备忘录。一个多功能办公设备可能在接收一个传真、扫描另一个传真并从某人的计算机打印文档。我们希望我们使用的 GUI 程序能够在更新屏幕的同时响应菜单。但普通的计算机程序一次只能做一件事。传统的计算机编程模型——一条接着一条地写语句,由重复循环和二进制决策构成——在本质上是顺序的。

顺序处理虽然简单直接,但效率不及其它方法高。为了提高性能,Java 提供了线程,即在单个应用程序或进程内处理多个控制流的能力。Java 提供线程支持,并且实际上要求线程:Java 运行时本身就是固有的多线程的。例如,窗口系统的动作处理和 Java 的垃圾收集(这种神奇的功能使我们可以避免在分配和释放内存时像其他低于或等于 C 语言级别的语言那样需要手动释放所有内容)是在不同的线程中运行的。

就像多任务处理允许单个操作系统在单处理器计算机上同时运行多个程序一样,多线程可以使单个程序或进程在同一时间内看起来像在处理多个任务。多线程导致更交互式的图形和更响应迅速的 GUI 应用程序(程序可以在窗口中绘制同时响应菜单,这两个活动几乎独立进行),更可靠的网络服务器(如果一个客户端出错,服务器继续与其他客户端通信),等等。

请注意,我在上一段中没有说“多处理”。有时候,多任务处理的术语被错误地称为多处理,但事实上,这个术语指的是不同的问题:即两个或更多个 CPU 在单个操作系统下运行的情况。多处理本身并不新鲜:IBM 大型机在 1970 年代就做过,Sun SPARCstations 在 1980 年代做过,Intel PC 在 1990 年代做过。自 2010 年中期以来,越来越难买到只包含一个处理器的计算机,除了手表之外的任何装置。真正的多处理允许您在多个 CPU 上同时运行多个进程。Java 对线程的支持包括多处理,只要操作系统支持。请参阅系统文档以获取详细信息。

尽管大多数现代操作系统都提供线程,但 Java 是第一种在语言中内置支持线程操作的主流编程语言。java.lang.Object的语义包括对象的监视器锁定的概念,一些方法(notifynotifyAllwait)仅在多线程应用程序的上下文中有意义。Java 还有诸如synchronized之类的语言关键字,用于控制线程应用程序的行为。

现在世界各地已经有多年与 Java 线程化的经验,专家们开始构建编写线程化应用程序的更好方法。并发实用程序是根据 JSR 166¹规范,并包含在所有现代 Java 发布版中,它们在计算机科学系的 Doug Lea 教授的util.concurrent包的基础上进行了大量的扩展。这个包旨在解决线程化困难,就像集合类(参见第七章)为数据结构化所做的那样。这不是一个小事业,但他们成功地完成了。

java.util.concurrent包包括几个主要部分:

  • Executors,线程池(ExecutorServices)和Futures/CompletableFutures

  • QueueBlockingQueue

  • 锁和条件,具有 JVM 支持的更快锁定和解锁

  • 同步器,包括SemaphoreBarrier

  • 原子变量

在本章中,我将专注于第一组,即线程池和Futures。

Executor接口的实现正如其名称所示,是一个可以为你执行代码的类。要执行的代码可以是熟悉的Runnable或新的接口Callable。一种常见的Executor类型是线程池Future接口表示已经启动的某事物的未来状态;它有等待结果准备好的方法。CompletableFutureFuture的实现,它增加了许多用于链式处理CompletableFuture和后应用方法的附加方法。

这些简要定义都是过于简化了。解决所有问题超出了本章的范围,但我提供了几个例子。

16.1 在不同线程中运行代码

问题

你需要编写一个线程化的应用程序。

解决方案

编写实现Runnable的代码;将其传递给Executor,或者实例化一个Thread并启动它。

讨论

有几种实现线程的方式,它们都要求你实现RunnableCallable接口。Runnable只有一个方法,并且不返回任何值;这是它的签名:

public interface java.lang.Runnable {
  public abstract void run();
}

Callable同样只有一个方法,但call()方法返回特定类型,因此接口有一个类型参数(这里是V,代表“值”):

public interface java.util.concurrent.Callable<V> {
  public abstract V call() throws Exception;
}

你必须提供 run()call() 方法的实现。这个方法没什么特别的;它是一个普通方法,你也可以自己调用它。但如果你这样做了,那又怎样呢?它不会像独立的控制流那样启动特殊的魔法,所以它不会与你的主程序或控制流并发运行。为了实现这一点,你需要调用线程创建的魔法。

使用线程的原始方式,不再一般推荐,是直接创建 Thread 对象并调用它们的 start() 方法,这会在新线程初始化后调用 run() 方法。原始线程模型中没有支持 Callable 接口。你可以通过以下几种方式之一创建线程:

  • 继承 java.lang.Thread(它实现了 Runnable),并重写 run() 方法。

  • 创建你的 Runnable 并将其传递给 Thread 构造函数。

  • 使用 Java 8+,如 Recipe 9.0 所示,你可以使用 lambda 表达式来实现 Runnable

由于性能等问题(Thread 对象的创建和销毁成本高昂,而且一旦其 run() 方法返回,线程就无法再使用),不再推荐采用这种方式调用线程。我不再展示这样做的示例。在线源代码中有一些示例,在 threads 目录下查看;特别是 ThreadsDemo4

相反,推荐的执行线程操作的方式是使用 java.util.concurrent 包的 ExecutorServiceExecutorService 如其名,是一个可以为你执行代码的服务类。要执行的代码可以是 RunnableCallable。你可以通过在 Executors 类上调用工厂方法来获取 ExecutorService。Example 16-1 中的代码展示了一个线程池的简单示例。

Example 16-1. main/src/main/java/threads/ThreadPoolDemo.java
        final ExecutorService pool = Executors.newFixedThreadPool(HOWMANY);
        List<Future<Integer>> futures = new ArrayList<>(HOWMANY);
        for (int i = 0; i < HOWMANY; i++) {
            Future<Integer> f = pool.submit(new DemoRunnable(i));
            System.out.println("Got 'Future' of type " + f.getClass());
            futures.add(f);
        }
        Thread.sleep(3 * 1000);
        done = true;
        for (Future<Integer> f : futures) {
            System.out.println("Result " + f.get());
        }
        pool.shutdown();

这将打印一系列如下的行,显示交错运行的线程:

Running Thread[pool-1-thread-3,5,main]
Running Thread[pool-1-thread-3,5,main]
Running Thread[pool-1-thread-1,5,main]
Running Thread[pool-1-thread-1,5,main]

注意,有几种提交方法,第一种在父接口 Executor 中,另外两种在 ExecutorService 中:

public void execute(Runnable);
public Future<T> submit(Callable<T>);
public Future<T> submit(Runnable);

也就是说,execute() 接受一个 Runnable 并且不返回任何内容,而 submit() 方法返回一个 Future<T>(对于方法 submit(Runnable),类型参数 T 总是 java.lang.Void)。

当你完成线程池的使用时,应该调用它的 shutDown() 方法。

16.2 用动画显示移动图像

问题

在程序的其他部分运行时,你需要更新图形显示。

解决方案

使用后台线程驱动动画。

讨论

线程的一个常见用途是动画程序,它显示一个移动的图像。这个动画程序正是这样做的。它在屏幕上不同位置绘制一个图形图像;每个图像的位置更新并从不同的Thread重新绘制,以便所有动画并行运行。您可以在图 16-1 中看到程序的运行情况。

jcb4 1601

Figure 16-1. 动画程序

动画程序的代码包括两个类,Sprite(参见示例 16-4)和Bounce²(参见示例 16-5)。Sprite是一个移动的图像;Bounce是主程序。

Example 16-4. main/src/main/java/threads/Sprite.java(动画程序的一部分)
/** A Sprite is one Image that moves around the screen on its own */
public class Sprite extends Component implements Runnable {
    private static final long serialVersionUID = 1L;
    protected static int spriteNumber = 0;
    protected int number;
    protected int x, y;
    protected Component parent;
    protected Image image;
    protected volatile boolean done = false;
    /** The time in mSec to pause between each move. */
    protected volatile int sleepTime = 250;
    /** The direction for this particular sprite. */
    protected Direction direction;
    enum Direction {
        VERTICAL, HORIZONTAL, DIAGONAL
    }
    /** Construct a Sprite with a Component parent, image and direction.
 * Construct and start a Thread to drive this Sprite.
 */
    public Sprite(Component parent, Image image, Direction direction) {
        this.parent = parent;
        this.image = image;
        this.direction = direction;
        this.number = Sprite.spriteNumber++;
        setSize(image.getWidth(this), image.getHeight(this));
    }

    /** Construct a Sprite with the default direction */
    public Sprite(Component parent, Image image) {
        this(parent, image, Direction.DIAGONAL);
    }

    /** Stop this Sprite. */
    public void stop() {
        System.out.println("Stopping " + number);
        done = true;
    }

    /** Adjust the motion rate */
    protected void setSleepTime(int n) {
        sleepTime = n;
    }

    /**
 * Run one Sprite around the screen.
 * This version just moves them around either across, down, or
 * at some 45-degree angle.
 */
    public void run() {
        int width = parent.getSize().width;
        int height = parent.getSize().height;
        // Set initial location
        x = (int)(Math.random() * width);
        y = (int)(Math.random() * height);
        // Flip coin for x & y directions
        int xincr = Math.random()>0.5?1:-1;
        int yincr = Math.random()>0.5?1:-1;
        while (!done) {
            width = parent.getSize().width;
            height = parent.getSize().height;
            if ((x+=xincr) >= width)
                x=0;
            if ((y+=yincr) >= height)
                y=0;
            if (x<0)
                x = width;
            if (y<0)
                y = height;
            switch(direction) {
                case VERTICAL:
                    x = 0;
                    break;
                case HORIZONTAL:
                    y = 0;
                    break;
                case DIAGONAL:
                    // Let it wrap around
                    break;
            }
            //System.out.println("from " + getLocation() + "->" + x + "," + y);
            setLocation(x, y);
            repaint();
            try {
                Thread.sleep(sleepTime);
            } catch (InterruptedException e) {
                return;
            }
        }
    }

    /** paint -- just draw our image at its current location */
    public void paint(Graphics g) {
        g.drawImage(image, 0, 0, this);
    }
}

本示例展示了volatile关键字的几个用法。volatile关键字用于通知 Java 一个变量可能会被多个线程修改,因此在使用时必须始终获取其当前值。如果没有此关键字,Java 可能会使用给定变量的缓存版本。当一个变量只在一个线程中使用时,这样可以提高性能,但(没有volatile时)当变量在一个线程中修改并在另一个线程中观察时可能导致不正确的结果。

Example 16-5. main/src/main/java/threads/Bounce.java(动画程序的一部分)
public class Bounce extends JPanel {

    private static final long serialVersionUID = -5359162621719520213L;
    /** The main Panel */
    protected JPanel p;
    /** The image, shared by all the Sprite objects */
    protected Image img;
    /** A Thread Pool */
    protected ExecutorService tp = Executors.newCachedThreadPool();
    /** A Vector of Sprite objects. */
    protected List<Sprite> v = new Vector<Sprite>(); // multithreaded, use Vector;

    public static void main(String[] args) {
        JFrame jf = new JFrame("Bounce Demo");
        jf.add(new Bounce(args.length > 0 ? args[0] : null));
        jf.setSize(300, 300);
        jf.setVisible(true);
        jf.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

    public Bounce(String imgName) {
        setLayout(new BorderLayout());
        JButton b = new JButton("Add a Sprite");
        b.addActionListener(e -> {
            System.out.println("Creating another one!");
            Sprite s = new Sprite(this, img);
            tp.execute(s);
            p.add(s);
            v.add(s);
        });
        add(b, BorderLayout.NORTH);
        add(p = new JPanel(), BorderLayout.CENTER);
        p.setLayout(null);
        if (imgName == null) imgName = "duke.gif";
        final URL resource = getClass().getResource("/" + imgName);
        if (resource == null) {
            throw new IllegalStateException("Could not load image " + imgName);
        }
        img = Toolkit.getDefaultToolkit().getImage(resource);
        MediaTracker mt = new MediaTracker(this);
        mt.addImage(img, 0);
        try {
            mt.waitForID(0);
        } catch(InterruptedException e) {
            throw new IllegalArgumentException(
                "InterruptedException while loading image " + imgName);
        }
        if (mt.isErrorID(0)) {
            throw new IllegalArgumentException(
                "Couldn't load image " + imgName);
        }
        JButton stopper = new JButton("Shut down");
        stopper.addActionListener(e -> {
            stop();
            tp.shutdown();
        });
        add(stopper, BorderLayout.SOUTH);
    }

    public void stop() {
        for (Sprite s : v) {
            s.stop();
        }
        v.clear();
        try {
            tp.awaitTermination(5, TimeUnit.SECONDS);
            System.out.println("ThreadPool is shut down, ending program");
            System.exit(0);
        } catch (InterruptedException e) {
            // Empty
        }
    }
}

16.3 停止线程

Problem

您需要停止一个线程。

解决方案

不要使用Thread.stop()方法;相反,在run()方法的主循环顶部使用一个测试的boolean

讨论

虽然可以使用线程的stop()方法,但不建议这样做。这是因为这种方法非常激烈,无法在具有多个活动线程的程序中可靠地表现。这也是为什么在尝试使用它时,编译器会生成弃用警告。推荐的方法是在run()方法的主循环中使用一个boolean变量。示例 16-6 中的程序在其shutDown()方法被调用后无限打印消息;然后将控制变量done设置为 false,从而终止循环。这导致run()方法返回,结束其处理。

Example 16-6. main/src/main/java/threads/StopBoolean.java
public class StopBoolean {

    // Must be volatile to ensure changes visible to other threads.
    protected volatile boolean done = false;

    Runnable r = () -> {
        while (!done) {
            System.out.println("StopBoolean running");
            try {
                Thread.sleep(720);
            } catch (InterruptedException ex) {
                // nothing to do
            }
        }
        System.out.println("StopBoolean finished.");
    };

    public void shutDown() {
        System.out.println("Shutting down...");
        done = true;
    }

    public void doDemo() throws InterruptedException {
        ExecutorService pool = Executors.newSingleThreadExecutor();
        pool.submit(r);
        Thread.sleep(1000*5);
        shutDown();
        pool.shutdown();
        pool.awaitTermination(2, TimeUnit.SECONDS);
    }

    public static void main(String[] args) throws InterruptedException {
        new StopBoolean().doDemo();
    }
}

运行时看起来像这样:

StopBoolean running
StopBoolean running
StopBoolean running
StopBoolean running
StopBoolean running
StopBoolean running
StopBoolean running
StopBoolean finished.

但如果您的线程在读取网络连接时被阻塞呢?此时您无法检查boolean,因为正在读取的线程处于休眠状态。这就是stop方法的设计初衷,但正如我们所见,它现在已被弃用。相反,您可以简单地关闭套接字。示例 16-7 中的程序故意通过从应该写入的套接字读取数据而导致死锁,以演示关闭套接字确实终止了循环。

Example 16-7. main/src/main/java/threads/StopClose.java
public class StopClose extends Thread {
    protected Socket io;

    public void run() {
        try {
            io = new Socket("java.sun.com", 80);    // HTTP
            BufferedReader is = new BufferedReader(
                new InputStreamReader(io.getInputStream()));
            System.out.println("StopClose reading");

            // The following line will deadlock (intentionally), since HTTP
            // enjoins the client to send a request (like "GET / HTTP/1.0")
            // and a null line, before reading the response.

            String line = is.readLine();    // DEADLOCK

            // Should only get out of the readLine if an interrupt
            // is thrown, as a result of closing the socket.

            // So we shouldn't get here, ever:
            System.out.printf("StopClose FINISHED after reading %s!?", line);
        } catch (IOException ex) {
            System.out.println("StopClose terminating: " + ex);
        }
    }

    public void shutDown() throws IOException {
        if (io != null) {
            // This is supposed to interrupt the waiting read.
            synchronized(io) {
                io.close();
            }
        }
        System.out.println("StopClose.shutDown() completed");
    }

    public static void main(String[] args)
    throws InterruptedException, IOException {
        StopClose t = new StopClose();
        t.start();
        Thread.sleep(1000*5);
        t.shutDown();
    }
}

运行时,它会打印一个关闭正在发生的消息:

StopClose reading
StopClose terminating: java.net.SocketException: Resource temporarily unavailable

“但是等等,”你说。“如果我想中断等待,但实际上不想终止套接字怎么办?”这确实是一个很好的问题,并且没有完美的答案。但是你可以中断正在读取的线程;读取会被java.io.InterruptedIOException中断,并且您可以重新尝试读取。本章源代码中的文件Intr.java展示了这一点。

16.4 会合和超时

问题

您需要知道某件事是否已经完成,或者它是否在某个时间长度内完成。

解决方案

将某物置于其自己的线程中,并调用其join()方法,可以选择是否设置超时值。

讨论

目标线程的join()方法用于暂停当前线程,直到目标线程完成(从其run()方法返回)。此方法有多种重载版本;不带参数的版本永远等待线程终止,而带有参数的版本最多等待指定的时间。举个简单的例子,我创建(并启动!)了一个简单的线程,它只是从控制台终端读取内容,而主线程只是等待它。当我运行程序时,它看起来是这样的:

darwinsys.com$ java threads.Join
Starting
Joining
Reading
hello from standard input # waits indefinitely for me to type this line
Thread Finished.
Main Finished.
darwinsys.com$

示例 16-8 列出了join()演示的代码。

示例 16-8. main/src/main/java/threads/Join.java
public class Join {
    public static void main(String[] args) {
        Thread t = new Thread() {
            public void run() {
                System.out.println("Reading");
                try {
                    System.in.read();
                } catch (java.io.IOException ex) {
                    System.err.println(ex);
                }
                System.out.println("Thread Finished.");
            }
        };
        System.out.println("Starting");
        t.start();
        System.out.println("Joining");
        try {
            t.join();
        } catch (InterruptedException ex) {
            // should not happen:
            System.out.println("Who dares interrupt my sleep?");
        }
        System.out.println("Main Finished.");
    }
}

正如您所看到的,它使用了一个内部类Runnable(参见第 16.1 节)在Thread t中可运行。

16.5 使用synchronized关键字同步线程

问题

您需要保护某些数据不被多个线程访问。

解决方案

使用 synchronized关键字来保护您希望保护的方法或代码。

讨论

我在第 13.5 节中简要讨论了synchronized关键字。该关键字指定一次只允许一个线程在给定对象实例中运行给定方法(或同一类中的任何其他同步方法)(对于静态方法,一次只允许一个线程运行该方法)。您可以同步方法或更小的代码块。同步整个方法更容易和安全,但这可能会导致阻塞线程更多地运行。您只需在方法上添加synchronized关键字。例如,Vector的许多方法(参见第 7.4 节)都是同步的,以确保在两个线程同时更新或检索时,向量不会变得损坏或提供不正确的结果。

请记住,线程几乎可以在任何时候被中断,在这种情况下,控制权将转移到另一个线程。考虑两个线程同时追加到数据结构的情况。假设我们有与Vector相同的方法,但是我们正在操作一个简单的数组。add()方法简单地将当前对象数用作数组索引,然后递增它:

public void add(Object obj) {
   data[max] = obj; ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
   max = max + 1;   ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
}

线程 A 和 B 都希望调用这个方法。假设线程 A 在1之后被中断,但在2之前被中断,然后线程 B 开始运行。

1

线程 B 执行1,覆盖了data[max]的内容;我们现在丢失了线程 A 传递的对象的所有引用!

2

然后线程 B 在2处增加max并返回。稍后,线程 A 再次运行;它在2处继续,并将max增加到最后一个有效对象之外。所以我们不仅丢失了一个对象,而且数组中有一个未初始化的引用。这种情况如图 16-2 所示。

jcb4 1602

图 16-2. 非线程安全的 add 方法的操作:正常和失败的更新

现在你可能会想,“没问题,我只需合并这两行代码!”:

data[max++] = obj;

就像游戏节目主持人有时会说的那样,“Bzzzzt!谢谢参与!” 这个改变使得代码变得更短,但对可靠性没有任何影响。中断不会方便地发生在 Java 语句边界;它们可以发生在程序对应的许多 JVM 机器指令中的任何一个之间。代码仍然可能在存储之后和增量之前被中断。唯一的好解决方案是使用适当的同步。

方法使用synchronized意味着对它的任何调用都将等待,如果一个线程已经开始运行该方法:

public synchronized void add(Object obj) {
    ...
}

任何时候你希望同步一些代码,但不是整个方法,可以在方法内使用未命名代码块的synchronized关键字,就像这样:

public void add(Object obj) {
    synchronized (someObject) {
        // this code will execute in one thread at a time
    }
}

同步对象的选择由您决定。有时候在包含代码的对象上同步是有意义的,就像在示例 16-9 中那样。对于同步访问ArrayList,使用ArrayList实例是合理的,就像这样:

synchronized(myArrayList) {
     if (myArrayList.indexOf(someObject) != -1) {
         // do something with it.
     } else {
         create an object and add it...
    }
}

示例 16-9 是一个 Web Servlet,我写了一个用于课堂使用的建议,来自 Learning Tree 的同事 Scott Weingust 的建议。³ 它允许您玩一个类似游戏节目主持人提问并且第一个按下蜂鸣器的人(按蜂鸣器键)尝试回答问题的风格的游戏。为了确保不会同时有两个人按蜂鸣器,代码在更新Boolean buzzed变量的代码周围使用了同步块。而且为了可靠性,任何访问这个Boolean的代码也是同步的。

示例 16-9. main/src/main/java/threads/BuzzInServlet.java
public class BuzzInServlet extends HttpServlet {

    /** The attribute name used throughout. */
    protected final static String WINNER = "buzzin.winner";

    /** doGet is called from the contestants web page.
 * Uses a synchronized code block to ensure that
 * only one contestant can change the state of "buzzed".
 */
    public void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
        ServletContext application = getServletContext();

        boolean iWon = false;
        String user = request.getRemoteHost() + '@' + request.getRemoteAddr();

        // Do the synchronized stuff first, and all in one place.
        synchronized(application) {
            if (application.getAttribute(WINNER) == null) {
                application.setAttribute(WINNER, user);
                application.log("BuzzInServlet: WINNER " + user);
                iWon = true;
            }
         }

        response.setContentType("text/html");
        PrintWriter out = response.getWriter();

        out.println("<html><head><title>Thanks for playing</title></head>");
        out.println("<body bgcolor=\"white\">");

        if (iWon) {
            out.println("<b>YOU GOT IT</b>");
            // TODO - output HTML to play a sound file :-)
        } else {
                out.println("Thanks for playing, " + request.getRemoteAddr());
                out.println(", but " + application.getAttribute(WINNER) +
                    " buzzed in first");
        }
        out.println("</body></html>");
    }

    /** The Post method is used from an Administrator page (which should
 * only be installed in the instructor/host's localweb directory).
 * Post is used for administrative functions:
 * 1) to display the winner;
 * 2) to reset the buzzer for the next question.
 */
    public void doPost(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
        ServletContext application = getServletContext();

        response.setContentType("text/html");
        HttpSession session = request.getSession();

        PrintWriter out = response.getWriter();

        if (request.isUserInRole("host")) {
            out.println("<html><head><title>Welcome back, " +
                request.getUserPrincipal().getName() + "</title><head>");
            out.println("<body bgcolor=\"white\">");
            String command = request.getParameter("command");
            if (command.equals("reset")) {

                // Synchronize what you need, no more, no less.
                synchronized(application) {
                    application.setAttribute(WINNER, null);
                }
                session.setAttribute("buzzin.message", "RESET");
            } else if (command.equals("show")) {
                String winner = null;
                synchronized(application) {
                    winner = (String)application.getAttribute(WINNER);
                }
                if (winner == null) {
                    session.setAttribute("buzzin.message",
                        "<b>No winner yet!</b>");
                } else {
                    session.setAttribute("buzzin.message",
                        "<b>Winner is: </b>" + winner);
                }
            }
            else {
                session.setAttribute("buzzin.message",
                    "ERROR: Command " + command + " invalid.");
            }
            RequestDispatcher rd = application.getRequestDispatcher(
                "/hosts/index.jsp");
            rd.forward(request, response);
        } else {
            out.println("<html><head><title>Nice try, but... </title><head>");
            out.println("<body bgcolor=\"white\">");
            out.println(
                "I'm sorry, Dave, but you know I can't allow you to do that.");
            out.println("Even if you are " + request.getUserPrincipal());
        }
        out.println("</body></html>");
    }
}

两个 HTML 页面导致 Servlet。参赛者页面简单地有一个大链接(<a href=/servlet/BuzzInServlet>)。锚点链接生成 HTML GET,因此 Servlet 引擎调用doGet()

<html><head><title>Buzz In!</title></head>
<body>
<h1>Buzz In!</h1>
<p>
<font size=+6>
<a href="servlet/BuzzInServlet">
Press here to buzz in!
</a>
</font>

HTML 很简单,但完成了工作。 图 16-3 展示了外观和感觉。

jcb4 1603

图 16-3. BuzzInServlet 运行中

游戏主持人可以访问一个具有 POST 方法的 HTML 表单,该方法调用 doPost() 方法。这会向游戏主持人显示获胜者并为下一题重置蜂鸣器。

<html><head><title>Reset Buzzer</title></head>
<body>
<h1>Display Winner</h1>
<p>
<b>The winner is:</b>
<form method="post" action="servlet/BuzzInServlet">
    <input type="hidden" name="command" value="show">
    <input type="hidden" name="password" value="syzzy">
    <input type="submit" name="Show" value="Show">
</form>
<h1>Reset Buzzer</h1>
<p>
<b>Remember to RESET before you ask the contestants each question!</b>
<form method="post" action="servlet/BuzzInServlet">
    <input type="hidden" name="command" value="reset">
    <input type="hidden" name="password" value="syzzy">
    <input type="submit" name="Reset" value="RESET!">
</form>

提供了一个密码;这里是硬编码的,但实际上密码可以来自属性文件(配方 7.10)或 servlet 初始化参数(如 Java Servlet Programming [O’Reilly] 中描述的):

游戏主持人功能显示在 图 16-4 中。

jcb4 1604

图 16-4. BuzzInServlet 游戏主持人功能

当然,为了更完整的游戏体验,Servlet 还会保持一个人们按顺序蜂鸣的 Stack(参见 配方 7.16),以防第一个人回答问题不正确。对此的访问也必须进行同步。

16.6 使用锁简化同步

问题

您希望更简单地同步线程。

解决方案

使用 java.util.concurrent.locks 中的 Lock 机制。

讨论

使用 java.util.concurrent.locks 包;其主要接口是 Lock。该接口有几种锁定和一种解锁方法。以下是使用它的一般模式:

Lock thelock = ....
try  {
        lock.lock( );
        // do the work that is protected by the lock
} finally {
        lock.unlock( );
}

unlock() 调用放在 finally 块中的目的当然是确保在发生异常时不会被绕过(代码也可能包含一个或多个 catch 块,根据所执行的工作需要)。

与传统的 synchronized 方法和代码块相比,使用 Lock 的改进之处在于它实际上看起来像是一个锁定操作!正如我所提到的,有多种锁定方法可用,如 表 16-1 所示。

表 16-1. Lock 类的锁定方法

返回类型 方法 含义
void lock( ) 获取锁,即使必须等待直到另一个线程释放它
boolean tryLock( ) 只有在当前锁空闲时才获取锁
boolean tryLock(long time, TimeUnit units) throws InterruptedException 尝试获取锁,但只等待指定的时间长度
void lockInterruptibly( ) throws InterruptedException 获取锁,除非被中断否则一直等待
void unlock( ) 释放锁

TimeUnit 类允许您指定指定时间量的单位,包括 TimeUnit.SECONDSTimeUnit.MILLISECONDSTimeUnit.MICROSECONDSTimeUnit.NANOSECONDS

在所有情况下,在再次锁定之前必须使用 unlock() 释放锁。

标准的Lock在许多应用程序中很有用,但根据应用程序的要求,其他类型的锁可能更合适。具有非对称负载模式的应用程序可能会从一种称为读写锁的常见模式中受益;我将其称为读写锁以强调可以有多个读取者但只能有一个写入者。实际上它是一对互联的锁;任意数量的读取者可以持有读锁并读取数据,只要数据不被写入(共享读取访问)。然而,尝试锁定写锁的线程将等待所有读取者完成,然后在写者完成之前将它们锁定(独占写入访问)。为支持此模式,ReadWriteLock接口和实现类ReentrantReadWriteLock都可用。该接口仅有两个方法,readLock()writeLock(),它们提供对适当Lock实现的引用。这些方法本身不会锁定或解锁锁;它们仅提供对它们的访问,因此通常可以看到像这样的代码:

rwlock.readLock( ).lock( );
...
rwlock.readLock( ).unlock( );

为了演示ReadWriteLock的实际应用,我编写了基于网络的投票应用程序的业务逻辑部分。它可以用于候选人的投票或更常见的网络投票。假设您在主页上显示结果,并且仅在有人点击投票响应时更改数据,此应用程序符合ReadWriteLock的预期标准之一——即您有更多的读取者而不是写入者。主类ReadersWritersDemo如示例 Example 16-10 所示。辅助类BallotBox在线上;它简单地跟踪投票并在请求时返回只读的Iterator。请注意,在读取线程的run()方法中,您可以在持有锁的同时获取迭代器,但在打印之前释放锁;这样可以提高并发性能,但根据您的应用程序可能需要额外的并发更新锁定。

示例 16-10. main/src/main/java/threads/ReadersWriterDemo.java
public class ReadersWriterDemo {
    private static final int NUM_READER_THREADS = 3;

    public static void main(String[] args) {
        new ReadersWriterDemo().demo();
    }

    /** Set this to true to end the program */
    private volatile boolean done = false;

    /** The data being protected. */
    private BallotBox theData;

    /** The read lock / write lock combination */
    private ReadWriteLock lock = new ReentrantReadWriteLock();

    /**
 * Constructor: set up some quasi-random initial data
 */
    public ReadersWriterDemo() {
        List<String> questionsList = new ArrayList<>();
        questionsList.add("Agree");
        questionsList.add("Disagree");
        questionsList.add("No opinion");
        theData = new BallotBox(questionsList);
    }

    /**
 * Run a demo with more readers than writers
 */
    private void demo() {

        // Start two reader threads
        for (int i = 0; i < NUM_READER_THREADS; i++) {
            new Thread() {
                public void run() {
                    while (!done) {
                        lock.readLock().lock();
                        try {
                            theData.forEach(p ->
                                System.out.printf("%s: votes %d%n",
                                    p.getName(),
                                    p.getVotes()));
                        } finally {
                            // Unlock in "finally" to be sure it gets done.
                            lock.readLock().unlock();
                        }

                        try {
                            Thread.sleep(((long)(Math.random()* 1000)));
                        } catch (InterruptedException ex) {
                            // nothing to do
                        }
                    }
                }
            }.start();
        }

        // Start one writer thread to simulate occasional voting
        new Thread() {
            public void run() {
                while (!done) {
                    lock.writeLock().lock();
                    try {
                        theData.voteFor(
                            // Vote for random candidate :-)
                            // Performance: should have one PRNG per thread.
                            (((int)(Math.random()*
                            theData.getCandidateCount()))));
                    } finally {
                        lock.writeLock().unlock();
                    }
                    try {
                        Thread.sleep(((long)(Math.random()*1000)));
                    } catch (InterruptedException ex) {
                        // nothing to do
                    }
                }
            }
        }.start();

        // In the main thread, wait a while then terminate the run.
        try {
            Thread.sleep(10 * 1000);
        } catch (InterruptedException ex) {
            // nothing to do
        } finally {
            done = true;
        }
    }
}

因为这是一次模拟,投票是随机的,所以不总是会得出 50/50 的结果。在两次连续运行中,以下是每次运行的最后一行:

Agree(6), Disagree(6)
Agree(9), Disagree(4)

参见

Lock接口还提供了Condition对象,提供了更多的灵活性。有关更多信息,请参阅在线文档。

16.7 使用队列接口简化生产者/消费者

问题

需要控制涉及多个线程的生产者/消费者实现。

解决方案

使用Queue接口或BlockingQueue子接口。

讨论

作为使用java.util.Concurrent包可能带来的简化示例,考虑标准的生产者/消费者程序。使用传统Thread代码(wait()notifyAll())同步的实现在在线源代码中称为ProdCons2。示例 16-11,ProdCons15.java,使用java.util.BlockingQueuejava.util.Queue的子接口)重新实现了ProdCons2,代码行数约为其三分之二,而且更简单。该应用程序简单地将项目放入队列中并从中取出。在示例中,我有四个生产者和只有三个消费者,因此生产者最终会等待。在我的一台较旧的笔记本电脑上运行该应用程序,生产者在大约 10 秒钟的运行时间内超过消费者约 350 个。

示例 16-11. main/src/main/java/threads/ProdCons15.java
public class ProdCons15 {

    protected volatile boolean done = false;

    /** Inner class representing the Producer side */
    class Producer implements Runnable {

        protected BlockingQueue<Object> queue;

        Producer(BlockingQueue<Object> theQueue) { this.queue = theQueue; }

        public void run() {
            try {
                while (!done) {
                    Object justProduced = getRequestFromNetwork();
                    queue.put(justProduced);
                    System.out.println(
                        "Produced 1 object; List size now " + queue.size());
                }
            } catch (InterruptedException ex) {
                System.out.println("Producer INTERRUPTED");
            }
        }

        Object getRequestFromNetwork() {    // Simulation of reading from client
            try {
                    Thread.sleep(10); // simulate time passing during read
            } catch (InterruptedException ex) {
                 System.out.println("Producer Read INTERRUPTED");
            }
            return new Object();
        }
    }

    /** Inner class representing the Consumer side */
    class Consumer implements Runnable {
        protected BlockingQueue<Object> queue;

        Consumer(BlockingQueue<Object> theQueue) { this.queue = theQueue; }

        public void run() {
            try {
                while (true) {
                    Object obj = queue.take();
                    int len = queue.size();
                    System.out.println("List size now " + len);
                    process(obj);
                    if (done) {
                        return;
                    }
                }
            } catch (InterruptedException ex) {
                    System.out.println("CONSUMER INTERRUPTED");
            }
        }

        void process(Object obj) {
            // Thread.sleep(123) // Simulate time passing
            System.out.println("Consuming object " + obj);
        }
    }

    ProdCons15(int nP, int nC) {
        BlockingQueue<Object> myQueue = new LinkedBlockingQueue<>();
        for (int i=0; i<nP; i++)
            new Thread(new Producer(myQueue)).start();
        for (int i=0; i<nC; i++)
            new Thread(new Consumer(myQueue)).start();
    }

    public static void main(String[] args)
    throws IOException, InterruptedException {

        // Start producers and consumers
        int numProducers = 4;
        int numConsumers = 3;
        ProdCons15 pc = new ProdCons15(numProducers, numConsumers);

        // Let the simulation run for, say, 10 seconds
        Thread.sleep(10*1000);

        // End of simulation - shut down gracefully
        pc.done = true;
    }
}

ProdCons15在几乎所有方面都优于ProdCons2。然而,输出的队列大小不再必然准确反映对象插入或移除后队列的大小。因为这里不再有任何锁定来确保原子性,在Producer线程的queue.put()Consumer线程的队列大小查询之间,其他线程可以执行任意数量的队列操作。

16.8 使用 Fork/Join 优化并行处理

问题

您想要优化多处理器和/或大问题空间的使用。

解决方案

使用 Fork/Join 框架。

讨论

Fork/Join 是一个主要用于可以自然递归地划分的相当大的任务的ExecutorService,在这里,您不必确保每个划分的时间相等。它使用工作窃取来使线程保持繁忙。

使用 Fork/Join 的基本方法是扩展RecursiveTaskRecursiveAction并根据这些行覆盖其compute()方法:

if (assigned portion of work is “small enough”) {
	perform the work myself
} else {
	split my work into two pieces
	invoke the two pieces and await the results
}

有两个类:RecursiveTaskRecursiveAction。主要区别在于RecursiveTask的每个工作步骤都返回一个值,而RecursiveAction则不返回。换句话说,RecursiveAction方法compute()的返回类型为void,而具有相同名称的RecursiveTask方法的返回类型为T,某些类型参数。当每次调用返回代表其总体任务子集的计算的值时,您可能会使用RecursiveTask,换句话说,要划分像汇总数据这样的问题—每个任务将汇总一部分并返回该部分。您可能会使用RecursiveAction来操作大型数据结构,在原地执行数据的某种变换。

这里有两个 Fork/Join 框架的演示,命名为每个子类的ForkJoinTask

  • RecursiveTaskDemo直接使用fork()join()

  • RecursiveActionDemo使用invokeAll()来调用两个子任务。invoke()只是一个fork()和一个join();而invokeAll()只是重复执行这个过程直到完成。比较示例 16-12 和 16-13 中的compute()版本,这将会让您明白。

示例 16-12. 主要/src/main/java/threads/RecursiveActionDemo.java
/** A trivial demonstration of the "Fork-Join" framework:
 * square a bunch of numbers using RecursiveAction.
 * We use RecursiveAction here b/c we don't need each
 * compute() call to return its result; the work is
 * accumulated in the "dest" array.
 * @see RecursiveTaskDemo when each computation has to return a value.
 * @author Ian Darwin
 */
public class RecursiveActionDemo extends RecursiveAction {

    private static final long serialVersionUID = 3742774374013520116L;

    static int[] raw = {
        19, 3, 0, -1, 57, 24, 65, Integer.MAX_VALUE, 42, 0, 3, 5
    };
    static int[] sorted = null;

    int[] source;
    int[] dest;
    int length;
    int start;
    final static int THRESHOLD = 4;

    public static void main(String[] args) {
        sorted = new int[raw.length];
        RecursiveActionDemo fb =
            new RecursiveActionDemo(raw, 0, raw.length, sorted);
        ForkJoinPool pool = new ForkJoinPool();
        pool.invoke(fb);
        System.out.print('[');
        for (int i : sorted) {
            System.out.print(i + ",");
        }
        System.out.println(']');
    }

    public RecursiveActionDemo(int[] src, int start, int length, int[] dest) {
        this.source = src;
        this.start = start;
        this.length = length;
        this.dest = dest;
      }

    @Override
    protected void compute() {
        System.out.println("RecursiveActionDemo.compute()");
        if (length <= THRESHOLD) { // Compute Directly
            for (int i = start; i < start + length; i++) {
                dest[i] = source[i] * source[i];
            }
        } else {                    // Divide and Conquer
            int split = length / 2;
            invokeAll(
              new RecursiveActionDemo(source, start,         split,          dest),
              new RecursiveActionDemo(source, start + split, length - split, dest));
        }
    }
}
示例 16-13. 主要/src/main/java/threads/RecursiveTaskDemo.java
/**
 * Demonstrate the Fork-Join Framework to average a large array.
 * Running this on a multi-core machine as e.g.,
 * $ time java threads.RecursiveTaskDemo
 * shows that the CPU time is always greater than the elapsed time,
 * indicating that we are making use of multiple cores.
 * That said, it is a somewhat contrived demo.
 *
 * Use RecursiveTask<T> where, as in this example, each call returns
 * a value that represents the computation for its subset of the overall task.
 * @see RecursiveActionDemo when each computation does not return a value,
 * e.g., when each is just working on some section of a large array.
 * @author Ian Darwin
 */
public class RecursiveTaskDemo extends RecursiveTask<Long> {

    private static final long serialVersionUID = 3742774374013520116L;

    static final int N = 10000000;
    final static int THRESHOLD = 500;

    int[] data;
    int start, length;

    public static void main(String[] args) {
        int[] source = new int[N];
        loadData(source);
        RecursiveTaskDemo fb = new RecursiveTaskDemo(source, 0, source.length);
        ForkJoinPool pool = new ForkJoinPool();
        long before = System.currentTimeMillis();
        pool.invoke(fb);
        long after = System.currentTimeMillis();
        long total = fb.getRawResult();
        long avg = total / N;
        System.out.println("Average: " + avg);
        System.out.println("Time :" + (after - before) + " mSec");
    }

    static void loadData(int[] data) {
        Random r = new Random();
        for (int i = 0; i < data.length; i++) {
            data[i] = r.nextInt();
        }
    }

    public RecursiveTaskDemo(int[] data, int start, int length) {
        this.data = data;
        this.start = start;
        this.length = length;
    }

    @Override
    protected Long compute() {
        if (length <= THRESHOLD) { // Compute Directly
            long total = 0;
            for (int i = start; i < start + length; i++) {
                total += data[i];
            }
            return total;
        } else {                    // Divide and Conquer
            int split = length / 2;
            RecursiveTaskDemo t1 =
                new RecursiveTaskDemo(data, start,         split);
            t1.fork();
            RecursiveTaskDemo t2 =
                new RecursiveTaskDemo(data, start + split, length - split);
            return t2.compute() + t1.join();
        }
    }
}

最大的未定义部分是“足够小的”;您可能需要进行一些实验,看看什么大小的块效果最好。或者,更好的做法是,编写更多使用反馈控制系统的代码,当动态调整参数时,测量系统吞吐量,并使系统自动达到特定计算机系统和运行时的最佳值。这留给读者作为扩展练习。

16.9 定时任务:未来时间,编辑器中的后台保存

问题

您需要在将来的固定时间安排某些事情。您需要定期保存用户的工作在交互式程序中。

解决方案

对于一次性的将来任务,请使用Timer服务和TimerTask对象。对于循环任务,可以使用后台线程,或使用Timer服务并重新计算下一次时间。对于更复杂的任务,比如每个第二个星期四的正午运行某些内容,请考虑使用第三方调度库,例如Quartz,或在 JavaEE/Jakarta 中使用EJB 定时器服务

讨论

有几种方式可以安排将来的事情。对于一次性安排,您可以使用java.util中的Timer服务。对于循环任务,您可以使用一个Runnable,它在循环中睡眠。

这里是java.utilTimer服务的示例。这些是使用此 API 的基础知识:

  1. 创建Timer服务对象。

  2. 使用它来安排TimerTask实例,其中包含指示日期和时间的传统Date对象。

示例代码在示例 16-14 中使用Item作为TimerTask的子类,以便基于读取具有年-月-日-小时-分钟 任务格式的行执行简单的通知操作。

2020 12 25 10 30 Get some sleep.
2020 12 26 01 27 Finish this program
2020 12 25 01 29 Document this program
示例 16-14. 主要/src/main/java/threads/ReminderService.java
public class ReminderService {

    /** The Timer object */
    Timer timer = new Timer();

    class Item extends TimerTask {
        String message;
        Item(String m) {
            message = m;
        }
        public void run() {
            message(message);
        }
    }

    public static void main(String[] argv) throws Exception {
        new ReminderService().loadReminders();
    }

    private String dfPattern = "yyyy MM dd hh mm ss";
    private SimpleDateFormat formatter = new SimpleDateFormat(dfPattern);

    protected void loadReminders() throws Exception {

        Files.lines(Path.of("ReminderService.txt")).forEach(aLine -> {

            ParsePosition pp = new ParsePosition(0);
            Date date = formatter.parse(aLine, pp);
            String task = aLine.substring(pp.getIndex());
            if (date == null) {
                System.out.println("Invalid date in " + aLine);
                return;
            }
            System.out.println("Date = " + date + "; task = " + task);
            timer.schedule(new Item(task), date);
        });
    }

在现实生活中,程序需要长时间运行,并使用一些更复杂的消息模式;这里我们只显示定时调度部分。

示例 16-15. 创建一个后台线程来处理后台保存,如大多数文字处理器中所示。

示例 16-15. 主要/src/main/java/threads/ReminderService.java
public class AutoSave extends Thread {
    /** The FileSave interface is implemented by the main class. */
    protected FileSaver model;
    /** How long to sleep between tries */
    public static final int MINUTES = 5;
    private static final int SECONDS = MINUTES * 60;

    public AutoSave(FileSaver m) {
        super("AutoSave Thread");
        setDaemon(true);        // so we don't keep the main app alive
        model = m;
    }

    public void run() {
        while (true) {        // entire run method runs forever.
            try {
                sleep(SECONDS*1000);
            } catch (InterruptedException e) {
                // do nothing with it
            }
            if (model.wantAutoSave() && model.hasUnsavedChanges())
                model.saveFile(null);
        }
    }

    // Not shown:
    // 1) saveFile() must now be synchronized.
    // 2) method that shuts down main program be synchronized on *SAME* object
}

/** Local copy of FileSaver interface, for compiling AutoSave demo. */
interface FileSaver {
    /** Load new model from fn; if null, prompt for new fname */
    public void loadFile(String fn);

    /** Ask the model if it wants AutoSave done for it */
    public boolean wantAutoSave();

    /** Ask the model if it has any unsaved changes, don't save otherwise */
    public boolean hasUnsavedChanges();

    /** Save the current model's data in fn.
 * If fn == null, use current fname or prompt for a filename if null.
 */
    public void saveFile(String fn);
}

正如您在run()方法中所看到的,这段代码会休眠五分钟(300 秒),然后检查是否需要执行任何操作。如果用户已关闭自动保存功能,或者自上次保存以来未做任何更改,则不需要执行任何操作。否则,我们会在主程序中调用saveFile()方法,将数据保存到当前文件中。最好将其保存到某个恢复文件中,就像更好的文字处理器所做的那样。

没有显示的是现在所有方法都必须是同步的。如果考虑用户在自动保存方法调用它时同时点击了保存按钮,或者在文件保存方法刚打开文件准备写入时用户点击了退出按钮,就很容易明白为什么要这样做。将数据保存到恢复文件的策略可以解决部分问题,但仍需要极大的注意。

另见

有关java.util.concurrent的详细信息,请参阅随 JDK 附带的文档。有关 JSR 166 的背景,请参阅Doug Lea 的主页及其JSR 166 页面

一本关于 Java 多线程的极好参考书是《Java Concurrency in Practice》(Brian Goetz 等人著,Addison-Wesley 出版)。

Project Loom: Fibers and Continuations旨在推广更易于使用、轻量级的并发机制。

¹ JSR 代表 Java 规范请求。Java 社区流程将提议和采纳的标准称为 JSR。详情请见http://www.jcp.org

² 标题掩盖了一些未实现的野心,即使在一些更花哨的动画演示中看到的反弹曲线也无法实现。

³ 一个servlet是用于与远程客户端交互的低级服务器端 API;今天可能会以 JavaServer Faces (JSF)处理程序的形式编写。

第十七章:反射,或“一个名为 Class 的类”

17.0 简介

java.lang.Class和反射包java.lang.reflect提供了许多机制,用于从 Java 虚拟机中收集信息。这些设施被统称为反射,允许您在运行时动态加载类,查找类中的方法和字段,生成它们的列表,并调用动态加载类的方法。甚至有一种机制可以让您在程序运行时从头开始构造一个类(实际上是从字节的数组)。这是 Java 让您接近其神奇、秘密的内部机制的方式。

JVM 本身是一个大型程序,通常用 C 和/或 C++编写,实现 Java 虚拟机抽象。您可以通过互联网获取 OpenJDK 和其他 JVM 的源代码,这可能需要几个月的学习时间。在这里,我们仅集中在几个方面,并且只从使用 JVM 设施的程序员的角度来看待,而不涉及其内部工作原理;这是一个实现细节,可能会因供应商的 JVM 而异。

我将从动态加载现有类开始,继续列出类的字段和方法以及调用方法,最后通过使用ClassLoader动态创建类来结束。Java 的一个更有趣的方面之一,也是其灵活性的原因(昔日的小程序、Servlet、Web 服务和其他动态 API),同时也曾是其被认为速度问题的一部分,是动态加载的概念。例如,即使是最简单的“Hello, Java”程序也必须加载您的HelloJava类的类文件,其父类的类文件(通常是java.lang.Object),PrintStream类(因为您使用了System.out),PrintStream的父类,以及IOException及其父类等。要看到这个过程,请尝试类似于以下的东西:

java -verbose HelloJava | more

举个例子,当小程序流行时,浏览器会通过互联网下载小程序的字节码文件并在您的桌面上运行它。它如何将类文件加载到正在运行的 JVM 中?我们在第 17.4 节中讨论了这部分 Java 的魔法。本章以 JDK 工具的替换版本javap和交叉引用工具结束,您可以使用它们来成为一位著名的 Java 作者,通过发布自己对完整 Java API 的参考。

17.1 获取类描述符

问题

您想从类名或实例中获取一个Class对象。

解决方案

如果类型名称在编译时已知,可以使用编译器关键字.class获取类实例,该关键字适用于任何在编译时已知的类型,甚至是八种基本类型。

否则,如果您有一个对象(一个类的实例),您可以调用java.lang.Object类的getClass()方法,它将返回该对象的类的Class对象(现在这是一个冗长的说法!):

        System.out.println("Trying the ClassName.class keyword:");
        System.out.println("Object class: " + Object.class);
        System.out.println("String class: " + String.class);
        System.out.println("String[] class: " + String[].class);
        System.out.println("Calendar class: " + Calendar.class);
        System.out.println("Current class: " + ClassKeyword.class);
        System.out.println("Class for int: " + int.class);
        System.out.println();

        System.out.println("Trying the instance.getClass() method:");
        System.out.println("Sir Robin the Brave".getClass());
        System.out.println(Calendar.getInstance().getClass());

当我们运行它时,我们看到这个:

C:\javasrc\reflect>java  ClassKeyword 
Trying the ClassName.class keyword:
Object class: class java.lang.Object
String class: class java.lang.String
String[] class: class Ljava.lang.String;
Calendar class: class java.util.Calendar
Current class: class ClassKeyword
Class for int: int

Trying the instance.getClass( ) method:
class java.lang.String
class java.util.GregorianCalendar

C:\javasrc\reflect>

没什么花哨的,但正如你所见,你可以获取几乎任何在编译时已知的Class对象,无论它是否是包的一部分。

17.2 查找和使用方法和字段

问题

你需要在任意类中找到任意方法或字段名称。

解决方案

使用反射包java.lang.reflect

讨论

如果你只想在一个特定的类中查找字段和方法,你不需要这个技巧;你可以简单地使用new创建一个类的实例,并直接引用它的字段和方法。但这允许你在任何类中查找方法和字段,即使是尚未编写的类!如同[Recipe 17.1 中创建的类对象一样,你可以获得构造函数列表、方法列表或字段列表。方法getMethods()列出了一个给定类的方法作为Method对象数组。类似地,getFields()返回一个Field对象列表。因为构造方法在 Java 中被特殊对待,还有一个getConstructors()方法,它返回一个Constructor对象数组。尽管Class在包java.lang中,但它返回的ConstructorMethodField对象在java.lang.``reflect中,所以你需要导入这个包。ListMethods类(参见示例 17-1)展示了如何在运行时已知类名的类中获取方法列表。

示例 17-1. main/src/main/java/reflection/ListMethods.java
public class ListMethods {
    public static void main(String[] argv) throws ClassNotFoundException {
        if (argv.length == 0) {
            System.err.println("Usage: ListMethods className");
            return;
        }
        Class<?> c = Class.forName(argv[0]);
        Constructor<?>[] cons = c.getConstructors();
        printList("Constructors", cons);
        Method[] meths = c.getMethods();
        printList("Methods", meths);
    }
    static void printList(String s, Object[] o) {
        System.out.println("*** " + s + " ***");
        for (int i=0; i<o.length; i++)
            System.out.println(o[i].toString());
    }
}

例如,你可以在类似于java.lang.String的类上运行示例 17-1,并得到一个相当长的方法列表;我只展示部分输出,让你看看它是什么样子的:

> java reflection.ListMethods java.lang.String
*** Constructors ***
public java.lang.String( )
public java.lang.String(java.lang.String)
public java.lang.String(java.lang.StringBuffer)
public java.lang.String(byte[])
// and many more...
*** Methods ***
public static java.lang.String java.lang.String.copyValueOf(char[])
public static java.lang.String java.lang.String.copyValueOf(char[],int,int)
public static java.lang.String java.lang.String.valueOf(char)
// and more valueOf( ) forms...
public boolean java.lang.String.equals(java.lang.Object)
public final native java.lang.Class java.lang.Object.getClass( )
// and more java.lang.Object methods...
public char java.lang.String.charAt(int)
public int java.lang.String.compareTo(java.lang.Object)
public int java.lang.String.compareTo(java.lang.String)

你可以看到,这可以扩展(几乎字面意义上)以编写一个BeanMethods类,该类仅列出在 JavaBean 中定义的 set/get 方法(参见 Recipe 15.4)。

或者,你可以找到一个特定的方法并调用它,或者找到一个特定的字段并引用它的值。让我们首先找到一个给定的字段,因为这是最简单的。示例 17-2 是一段代码,给定一个Object和一个字段的名称,找到该字段(获取一个Field对象),然后检索并打印该字段的值作为一个int

示例 17-2. main/src/main/java/reflection/FindField.java
public class FindField {

    public static void main(String[] unused)
    throws NoSuchFieldException, IllegalAccessException {

        // Create instance of FindField
        FindField gf = new FindField();

        // Create instance of target class (YearHolder defined below).
        Object o = new YearHolder();

        // Use gf to extract a field from o.
        System.out.println("The value of 'currentYear' is: " +
            gf.intFieldValue(o, "currentYear"));
    }

    int intFieldValue(Object o, String name)
    throws NoSuchFieldException, IllegalAccessException {
        Class<?> c = o.getClass();
        Field fld = c.getField(name);
        int value = fld.getInt(o);
        return value;
    }
}

/** This is just a class that we want to get a field from */
class YearHolder {
    /** Just a field that is used to show getting a field's value. */
    public int currentYear = Calendar.getInstance().get(Calendar.YEAR);
}

如果我们需要找到一个方法怎么办?最简单的方法是使用方法getMethod()invoke()。但这并不是完全微不足道的。假设有人给了我们一个对象的引用。我们不知道它的类,但被告知它应该有这个方法:

public void work(String s) { }

我们希望调用work()方法。要找到这个方法,我们必须创建一个Class对象的数组,每个数组项对应于参数列表中的一个项目。因此,在这种情况下,我们创建一个仅包含对String类对象的引用的数组。因为我们在编译时知道类的名称,所以我们将使用较短的调用String.class,而不是Class.forName()。这样加上方法名称作为字符串,就可以让我们进入Class对象的getMethod()方法。如果成功,我们就会得到一个Method对象。但你知道吗?为了调用这个方法,我们必须构造另一个数组,这次是一个包含实际传递给调用的数据的Object引用数组。当然,我们还需要这个类的实例,在这个实例的上下文中运行方法。对于这个演示类,我们只需要传递一个字符串,因为我们的数组只包含这个字符串。示例 17-3 是找到方法并调用它的代码。

示例 17-3. main/src/main/java/reflection/GetAndInvokeMethod.java
/**
 * Get a given method, and invoke it.
 * @author Ian F. Darwin, http://www.darwinsys.com/
 */
public class GetAndInvokeMethod {

    /** This class is just here to give us something to work on,
 * with a println() call that will prove we got into it.
 */
    static class X {
        public void work(int i, String s) {
            System.out.printf("Called: i=%d, s=%s%n", i, s);
        }
        // The main code does not use this overload.
        public void work(int i) {
            System.out.println("Unexpected call!");
        }
    }
    public static void main(String[] argv) {
        try {
            Class<?> clX = X.class; // or Class.forName("X");

            // To find a method we need the array of matching Class types.
            Class<?>[] argTypes = {
                int.class,
                String.class
            };

            // Now find a Method object for the given method.
            Method worker = clX.getMethod("work", argTypes);

            // To INVOKE the method, we need the invocation
            // arguments, as an Object array.
            Object[] theData = {
                42,
                "Chocolate Chips"
            };

            // The obvious last step: invoke the method.
            // First arg is an instance, null if static method
            worker.invoke(new X(), theData);

        } catch (Exception e) {
            System.err.println("Invoke() failed: " + e);
        }
    }
}

虽然不算微不足道,但也算不上糟糕。在大多数编程语言中,你无法在这里花费的 40 行内完成这项工作。

请注意:当方法的参数是原始类型(例如int)时,不要将Integer.class传递给getMethod()。相反,您必须使用表示原始类型int的类对象。找到这个类的最简单方法是在Integer类中,有一个名为TYPE的公共常量,所以你应该传递Integer.TYPE。对于所有原始类型,情况都是如此;每个对应的包装类都有一个称为TYPE的原始类引用。

Java 还包括一个称为MethodHandle的机制,旨在简化和泛化 Reflection API 用于调用方法;我们在这里不涉及它,因为在实践中它并未显示出显著改进。

17.3 通过 Reflection 访问私有方法和字段

问题

您想要访问私有字段,并且听说可以使用 Reflection API 来实现。

解决方案

通常访问私有字段是一个坏主意。但如果确实需要,并且SecurityManager允许您使用 Reflection,那么您可以做到。

讨论

有时候需要访问其他类中的私有字段。例如,最近我写了一个 JUnit 测试案例,需要查看目标类的所有字段。秘密在于在尝试获取值或调用方法之前,调用FieldMethod描述符的setAccessible()方法并传递值true。就像在示例 17-4 中展示的那样,它确实如此简单。

示例 17-4. main/src/main/java/reflection/DefeatPrivacy.java
class X {
    @SuppressWarnings("unused") // Used surreptitiously below.
    private int p = 42;
    int q = 3;
}

/**
 * Demonstrate that it is, in fact, all too easy to access private members
 * of an object using Reflection, using the default SecurityManager
 */
public class DefeatPrivacy {

    public static void main(String[] args) throws Exception {
        new DefeatPrivacy().process();
    }

    private void process() throws Exception {
        X x = new X();
        System.out.println(x);
        // System.out.println(x.p); // Won't compile
        System.out.println(x.q);
        Class<? extends X> class1 = x.getClass();
        Field[] flds = class1.getDeclaredFields();
        for (Field f : flds) {
            f.setAccessible(true);    // bye-bye "private"
            System.out.println(f + "==" + f.get(x));
            f.setAccessible(false);    // reset to "correct" state
        }
    }
}
警告

使用时要极度小心,因为它可能违背 Java 编程中最珍视的一些原则。

17.4 加载和动态实例化类

问题

您想要动态加载类,就像 Web 服务器加载您的 Servlet 一样。

解决方案

使用 class.forName("ClassName"); 和类的 newInstance( ) 方法。

讨论

假设您正在编写一个 Java 应用程序,并希望其他开发人员能够通过编写在您应用程序上下文中运行的 Java 类来扩展您的应用程序。换句话说,这些开发人员实质上是使用 Java 作为扩展语言,就像小程序是 Web 浏览器的扩展一样。您可能希望定义一小组这些扩展程序将具有的方法,您可以调用这些方法进行初始化、操作和终止等目的。当然,最好的方法是发布一个给定的、可能是抽象的类,提供这些方法,并让开发人员从中继承。听起来耳熟能详吗?应该是的。这正是像网景这样的 Web 浏览器允许小程序部署的方式。

暂且不论安全性和通过网络套接字加载类文件的复杂问题,并假设用户可以将类安装到应用程序目录或在程序运行时出现在 CLASSPATH 中的目录中。首先,让我们定义我们的类。我们将其称为 Cooklet(参见 示例 17-5),以避免侵犯过度使用的 applet 这个词。假设每个子类都代表了驱动某种复杂食品准备和烹饪设备的代码,通过传统食谱的步骤制作饼干。在复杂化之前,我们将最初从成分到饼干的最简单路径开始。

示例 17-5. Cooklet.java
/** A simple class, just to provide the list of methods that
 * users need to provide to be usable in our application.
 * Note that the class is abstract so you must subclass it,
 * but the methods are non-abstract so you don't have to provide
 * dummy versions if you don't need a particular functionality.
 */
public abstract class Cooklet {

    /** The initialization method. The Cookie application will
 * call you here (AFTER calling your no-argument constructor)
 * to allow you to initialize your code
 */
    public void initialize( ) {
    }

    /** The work method. The cookie application will call you
 * here when it is time for you to start cooking.
 */
    public void work( ) {
    }

    /** The termination method. The cookie application will call you
 * here when it is time for you to stop cooking and shut down
 * in an orderly fashion.
 */
    public void terminate( ) {
    }
}

现在,因为我们将要为其他人提供这个,我们可能也想做一个演示版本;参见 示例 17-6。

示例 17-6. main/src/main/java/reflection/DemoCooklet.java
public class DemoCooklet extends Cooklet {
    public void work() {
        System.out.println("I am busy baking cookies.");
    }
    public void terminate() {
        System.out.println("I am shutting down my ovens now.");
    }
}

但我们的应用程序如何使用它呢?一旦我们有了用户类的名称,我们需要为该类创建一个 Class 对象。这可以通过静态方法 Class.forName() 轻松完成。然后我们可以使用 Class 对象的 newInstance() 方法创建它的实例;这将调用类的无参构造函数。然后我们只需将新构造的对象强制转换为我们的 Cooklet 类,并且我们就可以调用它的方法了!实际上,描述这段代码比看代码要花更长的时间,所以现在让我们来看看代码;参见 示例 17-7。

示例 17-7. main/src/main/java/reflection/Cookies.java
public class Cookies {
    public static void main(String[] argv) {
        System.out.println("Cookies Application Version 0.0");
        Cooklet cooklet = null;
        String cookletClassName = argv[0];
        try {
            Class<Cooklet> cookletClass =
                (Class<Cooklet>) Class.forName(cookletClassName);
            cooklet = cookletClass.newInstance();
        } catch (Exception e) {
            System.err.println("Error " + cookletClassName + e);
        }
        cooklet.initialize();
        cooklet.work();
        cooklet.terminate();
    }
}

如果我们运行它呢?

$ java Cookies DemoCooklet
Cookies Application Version 0.0
I am busy baking cookies.
I am shutting down my ovens now.
$

当然,这个版本的错误处理能力相对较弱。但您已经知道如何解决这个问题。如果要加载任何中等规模的应用程序类集合,您的 ClassLoader 还可以通过构建一个 Package 对象将类放入包中。

17.5 使用类加载器从头构建类

问题

您需要从非标准位置加载类并运行其方法。

解决方案

检查现有的加载器,例如java.net.URLClassLoader。如果没有合适的加载器,可以编写并使用自己的ClassLoader

讨论

当然,ClassLoader是一个加载类的程序。Java 虚拟机内置了一个ClassLoader,但你的应用程序可以根据需要创建其他ClassLoader。学习编写和运行工作中的ClassLoader,并使用它来加载类并运行其方法,是一项非常不平凡的任务。事实上,你很少需要编写ClassLoader,但了解如何编写是有助于理解 JVM 如何查找类、创建对象和调用方法的过程。

ClassLoader本身是抽象的;你必须派生它,通常提供一个按你希望的方式加载类的loadClass()方法。它可以从网络连接、本地磁盘、RAM、串口或任何其他位置加载字节。或者,如果你有编译器的访问权限,你可以自己在内存中构造类文件。

如果你只需通过 Web 协议(或更广泛地通过一个或多个 URL)加载类,则可以使用名为java.net.URLClassLoader的通用加载器。

你必须调用ClassLoaderloadClass()方法来显式加载任何你希望加载的类。请注意,此方法用于加载你加载的所有类所需的所有类(例如尚未加载的超类)。然而,JVM 仍会正常通过类路径加载使用new运算符实例化的类。

当编写ClassLoader时,你的loadClass()方法需要将类文件读入字节数组(通常是通过读取),将数组转换为Class对象,并返回结果。

什么?听起来有点像“然后奇迹发生了……”确实如此。然而,类创建的奇迹发生在 JVM 内部,在那里你无法访问它。相反,你的ClassLoader必须调用其超类(即java.lang.ClassLoader)中的protected defineClass()方法。这在 Figure 17-1 中有所示,其中一个包含假想Chicken类的字节流通过调用defineClass()方法在 JVM 中被转换为一个准备运行的Chicken类。

jcb4 1701

图 17-1. ClassLoader的实际应用

接下来怎么办?

要使用你的ClassLoader子类,你需要实例化它,并调用其loadClass()方法,提供你想要加载的类的名称。这将为你提供一个命名类的Class对象;Class对象反过来让你构造实例、查找和调用方法等。参见 Recipe 17.2。

17.6 使用 JavaCompiler 从头构建类

问题

你可能更喜欢通过生成源代码并编译来动态构建类。

解决方案

使用javax.tools中的JavaCompiler

讨论

有许多情况下,您可能需要动态生成代码。如果您正在编写一个框架,可能希望内省模型类以找到其字段,并动态生成访问器。正如我们在 Recipe 17.2 中看到的那样,可以使用Field类实现此目的。然而,对于高容量操作,生成直接访问代码可能更有效。

Java 编译器 API 自 Java 1.6 就存在,对于简单情况相当易于使用。以下是基本步骤:

  • 获取当前 Java 运行时的JavaCompiler对象。如果不可用,要么完全放弃,要么退回到使用反射。

  • 获取一个CompilerTask(也是Callable)来运行编译,传递输入和输出。

  • 调用Callable,可以直接调用或使用ExecutorService

  • 检查结果。如果为 true,则调用该类。

这在 Example 17-8 中有所展示。

示例 17-8. main/src/main/java/reflection/JavaCompilerDemo.java
package reflection;

import java.lang.reflect.Method;
import java.net.URI;
import java.util.List;
import java.util.concurrent.Callable;

// tag::main[] import javax.tools.JavaCompiler;
import javax.tools.SimpleJavaFileObject;
import javax.tools.ToolProvider;

/** Demo the Java Compiler API: Create a class, compile, load, and run it.
 * N.B. Will not run under Eclipse due to classpath settings;
 * best run it standalone using "java JavaCompiler.java"
 * @author Ian Darwin
 */
public class JavaCompilerDemo {
    private final static String PACKAGE = "reflection";
    private final static String CLASS = "AnotherDemo";
    private static boolean verbose;
    public static void main(String[] args) throws Exception {
        String source = "package " + PACKAGE + ";\n" +                  ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
            "public class " + CLASS + " {\n" +
            "\tpublic static void main(String[] args) {\n" +
            "\t\tString message = (args.length > 0 ? args[0] : \"Hi\")" + ";\n" +
            "\t\tSystem.out.println(message + \" from AnotherDemo\");\n" +
            "\t}\n}\n";
        if (verbose)
            System.out.print("Source to be compiled:\n" + source);

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();   ![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        if (compiler == null) {
            throw new IllegalStateException("No default compiler, giving up.");
        }
        Callable<Boolean> compilation =
            compiler.getTask(null, null, null, List.of("-d","."), null, ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
            List.of(new MySource(CLASS, source)));
        boolean result = compilation.call();                            ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
        if (result) {
            System.out.println("Compiled OK");
            Class<?> c = Class.forName(PACKAGE + "." + CLASS);          ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
            System.out.println("Class = " + c);
            Method m = c.getMethod("main", args.getClass());            ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/6.png)
            System.out.println("Method descriptor = " + m);
            Object[] passedArgs = { args };
            m.invoke(null, passedArgs);                                 ![7](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/7.png)
        } else {
            System.out.println("Compilation failed");
        }
    }
}
// end::main[] 
class MySource extends SimpleJavaFileObject {
    final String source;
    MySource(String fileName, String source) {
        super(URI.create("string:///" + fileName.replace('.', '/') +
                Kind.SOURCE.extension), Kind.SOURCE);
        this.source = source;
    }
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return source;
    }
}

1

我们要编译的源代码。在实际应用中,它可能是动态生成的,可能使用StringBuffer

2

获取默认的JavaCompiler对象的引用。

3

要求编译器创建一个CompilerTask来执行编译。CompilerTask也是Callable,我们将其保存为该类型。-d.是标准的javac参数。MySource扩展了编译器提供的 API 类SimpleJavaFileObject,通过创建file:// URL 来访问文件。

4

可以将Callable放入线程池(ExecutorService)(参见 Recipe 16.1);虽然我们不需要此功能,但编译器 API 返回它。我们直接调用Callable

5

假设resulttrue,表示成功,我们使用Class.forName()加载该类。

6

我们必须找到生成类中的main()方法。我们重用String[].class类型来传递 args,因为所有的main方法都有相同的参数。

7

最后,我们可以调用main方法,重用传入的args数组来传递任何欢迎消息。

运行此程序时,带参数和不带参数都显示参数正确传递给生成的AnotherDemo类:

$ java src/main/java/reflection/JavaCompilerDemo.java
Compiled OK
Class = class reflection.AnotherDemo
Method descriptor = public static void
  reflection.AnotherDemo.main(java.lang.String[])
Hi from AnotherDemo
$ java src/main/java/reflection/JavaCompilerDemo.java Welcome
Compiled OK
Class = class reflection.AnotherDemo
Method descriptor = public static void
  reflection.AnotherDemo.main(java.lang.String[])
Welcome from AnotherDemo
$

编译器 API 中有很多值得探索的内容,包括JavaFileManager,它允许您控制类文件的放置(除了像我们在此处使用-d一样),监听器以监视编译过程,以及控制输出和错误流。有关详细信息,请参阅javax.tools.JavaCompiler文档

17.7 性能计时

问题

性能慢?

解决方案

使用分析工具,或者在调用目标方法之前和之后使用System.currentTimeMillis()来计时单个方法;差异即为该方法所需的时间。

讨论

分析工具

分析工具——分析器——作为程序员工具包中的重要工具,拥有悠久的历史。商业分析工具将通过显示每个方法被调用的次数和每次调用所需的时间来帮助找出程序中的瓶颈。

通过使用VisualVM工具可以从 Java 应用程序中获得大量有用的信息,该工具是 Oracle JDK 直到 Java 8 的一部分。从 Java 9 开始,该工具已开源,现在可以从VisualVM 项目中获取。

JDK 的另一个工具是Java Flight Recorder,现在已开源并内置于 JDK 中。其数据旨在由Java Mission Control分析。还有第三方分析工具将提供更详细的信息;在网上搜索将找到当前的商业产品。

测量单个方法

最简单的技术是在动态加载主程序之前和之后保存 JVM 累积时间,然后计算这些时间的差异。要做到这一点的代码在 Example 17-11 中有所呈现;现在只需记住我们有一种计时给定 Java 类的方法。

衡量特定操作效率的一种方式是在隔离环境中多次运行它。因此,程序运行所需的总时间大致相当于多次调用相同操作的总时间。如果您想知道两种方法哪种更有效率,可以比较这些粗略的数字。考虑字符串连接与println()的情况。代码

println("Time is " + n.toString( ) + " seconds");

可能会通过创建一个StringBuilder来工作;附加字符串"Time is",值为n的字符串和"seconds";最后将完成的StringBuilder转换为String并传递给println()。假设您有一个执行大量此操作的程序,例如创建大量 HTML 的 Java servlet,并且您期望(或至少希望)您的网站足够繁忙,以便有效地执行此操作将产生差异。有两种思考方式:

  • 理论 A:这种字符串连接效率低下。

  • 理论 B:字符串连接并不重要;println()也效率低下。

支持理论 A 的人可能会说,因为 println() 只是把内容放入缓冲区,所以非常快,而字符串连接才是昂贵的部分。

如何在理论 A 和理论 B 之间做出决定?假设你愿意编写一个简单的测试程序来测试这两个理论。我们可以分别用两种方法编写一个简单的程序并计时。示例 17-9 是理论 A 的计时程序。

Example 17-9. main/src/main/java/performance/StringPrintA.java
public class StringPrintA {
    public static void main(String[] argv) {
        Object o = "Hello World";
        for (int i=0; i<100000; i++) {
            System.out.println("<p><b>" + o.toString() + "</b></p>");
        }
    }
}

StringPrintAA(在 javasrc 仓库中但此处未列出)与 StringBuilder 明确用于字符串连接是相同的。示例 17-10 是理论 B 的测试程序。

示例 17-10. main/src/main/java/performance/StringPrintB.java
public class StringPrintB {
    public static void main(String[] argv) {
        Object o = "Hello World";
        for (int i=0; i<100000; i++) {
            System.out.print("<p><b>");
            System.out.print(o.toString());
            System.out.print("</b></p>");
            System.out.println();
        }
    }
}

计时结果

我在同一台计算机上两次运行了 StringPrintAStringPrintAAStringPrintB。为了消除 JVM 启动时间,我从一个名为 TimeNoArgs 的程序中运行它们,该程序通过 Reflection API 调用类名并调用其 main() 方法。TimeNoArgs 和一个用于运行它的 shell 脚本 stringprinttimer.sh,位于 javasrc 源代码库的 performance 文件夹中。以下是结果:

2004 年程序
StringPrintA 17.23, 17.20 秒
StringPrintAA 17.23, 17.23 秒
StringPrintB 27.59, 27.60 秒
2014 年程序
--- ---
StringPrintA 0.714, 0.525 秒
StringPrintAA 0.616, 0.561 秒
StringPrintB 1.091, 1.039 秒

尽管由于 JVM 的改进和更快的硬件,十年来时间已经减少了大约 20 倍,但比率仍然保持了显著的一致性:StringPrintB 多次调用 print()println(),大约需要两倍的时间。

教训:不要猜测。如果重要,就计时。

另一个教训:多次调用 System.out.print() 比同样次数调用 StringBuilderappend() 方法成本更高,大约是 1.5 倍(或 150%)。理论 B 胜出;额外的 println 调用似乎节省了字符串连接,但使程序运行时间显著延长。

性能的其他方面:GC

软件性能的其他方面很多。在 Java 中,其中一个基本方面是垃圾回收行为。Sun/Oracle 通常在 JavaOne 上讨论这个问题。例如,参见 2003 年 JavaOne 展示文档 “Java HotSpot 虚拟机中的垃圾收集”。还可以查看由相同 GC 开发团队在 2007 年 JavaOne 上的演讲 “垃圾收集友好的编程”。JavaOne 2010 展示了更新的演示文稿 “垃圾收集神话破除者”

一个计时程序

在 Java 中构建一个简化的 time 命令非常容易,只要您有 System.currentTimeMillis() 就可以开始了。运行我的 Time 程序,在命令行上指定要计时的类的名称,后面跟上该类运行所需的参数(如果有的话)。程序显示在 示例 17-11 中。显示出类所花费的时间。但请记住,System.currentTimeMillis() 返回的是时钟时间,不一定是 CPU 时间。因此,您必须在一台没有运行大量后台进程的机器上运行它。还要注意,我使用动态加载(参见 配方 17.4)允许您在命令行上放置 Java 类名。

示例 17-11. main/src/main/java/performance/Time.java
public class Time {
    public static void main(String[] argv) throws Exception {
        // Instantiate target class, from argv[0]
        Class<?> c = Class.forName(argv[0]);

        // Find its static main method (use our own argv as the signature).
        Class<?>[] classes = { argv.getClass() };
        Method main = c.getMethod("main", classes);

        // Make new argv array, dropping class name from front.
        // Normally Java doesn't get the class name, but in
        // this case the user puts the name of the class to time
        // as well as all its arguments...
        String nargv[] = new String[argv.length - 1];
        System.arraycopy(argv, 1, nargv, 0, nargv.length);

        Object[] nargs = { nargv };

        System.err.println("Starting class " + c);

        // About to start timing run. Important to not do anything
        // (even a println) that would be attributed to the program
        // being timed, from here until we've gotten ending time.

        // Get current (i.e., starting) time
        long t0 = System.currentTimeMillis();

        // Run the main program
        main.invoke(null, nargs);

        // Get ending time, and compute usage
        long t1 = System.currentTimeMillis();

        long runTime = t1 - t0;

        System.err.println(
             "runTime="  + Double.toString(runTime/1000D));
    }
}

当然,您不能直接将操作系统 time 命令的结果与运行此程序的结果进行比较。存在一个相当大但是相当恒定的初始化开销——例如 JVM 启动和 Object 以及 System.out 的初始化——前者包含在内,后者排除在外。甚至可以说我的 Time 程序更加准确,因为它排除了这种恒定的开销。但是,正如前面提到的,必须在单用户机器上运行才能产生可重复的结果。而且,在等待计时程序完成时,在另一个窗口中运行编辑器是不公平的!

参见

Java 性能 由斯科特·奥克斯(O’Reilly)编著,提供了调优 Java 性能的信息。

17.8 打印类信息

问题

您想要打印类的所有信息,类似于 javap 的方式。

解决方案

获取一个 Class 对象,调用其 getFields()getMethods() 方法,并打印结果。

讨论

JDK 包含一个名为 javap 的程序,即 Java 打印机。Sun 的 JDK 版本通常打印类文件的大纲——方法和字段列表——但也可以打印 Java 字节码或机器指令。Kaffe 软件包没有包含 javap 的版本,因此我编写了一个并贡献了它(见 示例 17-12)。Kaffe 的开发人员稍作扩展,但基本上与我的版本相同。我的版本不打印字节码;它的行为类似于在没有给出命令行选项的情况下 Sun 的行为。

getFields()getMethods() 方法分别返回 FieldMethod 的数组;它们都位于 java.lang.reflect 包中。我使用 Modifiers 对象获取字段和方法的权限和存储属性的详细信息。在许多 Java 实现中,您可以跳过这一步骤,并简单地对每个 FieldMethod 对象调用 toString() 方法(正如我在这里对 Constructors 所做的那样)。这样做使我能够更加控制格式。

示例 17-12. main/src/main/java/reflection/MyJavaP.java
public class MyJavaP {

    /** Simple main program, construct self, process each class name
 * found in argv.
 */
    public static void main(String[] argv) {
        MyJavaP pp = new MyJavaP();

        if (argv.length == 0) {
            System.err.println("Usage: MyJavaP className [...]");
            System.exit(1);
        } else for (int i=0; i<argv.length; i++)
            pp.doClass(argv[i]);
    }

    /** Format the fields and methods of one class, given its name.
 */
    protected void doClass(String className) {
        try {
            Class<? extends Object> c = Class.forName(className);

            final Annotation[] annotations = c.getAnnotations();
            for (Annotation a : annotations) {
                System.out.println(a);
            }

            System.out.println(c + " {");

            Field fields[] = c.getDeclaredFields();
            for (Field f : fields) {
                final Annotation[] fldAnnotations = f.getAnnotations();
                for (Annotation a : fldAnnotations) {
                    System.out.println(a);
                }
                if (!Modifier.isPrivate(f.getModifiers()))
                    System.out.println("\t" + f + ";");
            }

            Constructor<? extends Object>[] constructors = c.getConstructors();
            for (Constructor<? extends Object> con : constructors) {
                System.out.println("\t" + con + ";");
            }

            Method methods[] = c.getDeclaredMethods();
            for (Method m : methods) {
                final Annotation[] methodAnnotations = m.getAnnotations();
                for (Annotation a : methodAnnotations) {
                    System.out.println(a);
                }
                if (!Modifier.isPrivate(m.getModifiers())) {
                    System.out.println("\t" + m + ";");
                }
            }
            System.out.println("}");
        } catch (ClassNotFoundException e) {
            System.err.println("Error: Class " +
                className + " not found!");
        } catch (Exception e) {
            System.err.println("JavaP Error: " + e);
        }
    }
}

17.9 列出包中的类

问题

想要获取一个包中所有类的列表。

解决方案

你通常无法在一般情况下做到这一点。有一些有限的方法,大多涉及CLASSPATH扫描。

讨论

没有办法找出包中的所有类,部分原因是,正如我们在 Recipe 17.5 中看到的,您可以随时向包中添加类!而且,不管好坏,JVM 和标准类(例如java.lang.Package)甚至不允许您枚举当前给定包中的类。

最接近的方法是查看CLASSPATH。但这仅适用于本地目录和 JAR 文件;如果有本地定义或网络加载的类,则无法帮助。换句话说,它会找到编译后的类,但不包括动态加载的类。有几个库可以为您自动化此过程,欢迎使用。扫描CLASSPATH的代码本质上非常简单,所以具有热情的优秀开发人员将希望仔细研究它。示例 17-13 展示了我的ClassesInPackage类及其一个静态方法。该代码可以运行,但对错误处理非常短,遇到不存在的包或其他失败情况将崩溃。

该代码经历了一些变动,将CLASSPATH作为 URL 枚举进行检查,然后查看每个元素。

文件

URL 将包含包含.class文件的文件路径名,因此我们可以直接列出它。

jar

URL 包含文件名,格式为“file:/path_to_jar_file!package/name”,因此我们需要将其分开;在这种情况下,“包名称”后缀稍显冗余,因为它是我们要求ClassLoader给出的包。

示例 17-13. main/src/main/java/reflection/ClassesInPackage.java
public class ClassesInPackage {

    /** This approach began as a contribution by Paul Kuit at
 * http://stackoverflow.com/questions/1456930/, but his only
 * handled single files in a directory in classpath, not in Jar files.
 * N.B. Does NOT handle system classes!
 * @param packageName
 * @return
 * @throws IOException
 */
    public static String[] getPackageContent(String packageName)
        throws IOException {

        final String packageAsDirName = packageName.replace(".", "/");
        final List<String> list = new ArrayList<>();
        final Enumeration<URL> urls =
                Thread.currentThread().
                getContextClassLoader().
                getResources(packageAsDirName);
        while (urls.hasMoreElements()) {
            URL url = urls.nextElement();
            // System.out.println("URL = " + url);
            String file = url.getFile();
            switch (url.getProtocol()) {
            case "file":
                // This is the easy case: "file" is
                // the full path to the classpath directory
                File dir = new File(file);
                for (File f : dir.listFiles()) {
                    list.add(packageAsDirName + "/" + f.getName());
                }
                break;
            case "jar":
                // This is the harder case; "file" is of the form
                // "jar:/home/ian/bleah/darwinsys.jar!com/darwinsys/io"
                // for some jar file that contains at least one class from
                // the given package.
                int colon = file.indexOf(':');
                int bang = file.indexOf('!');
                String jarFileName = file.substring(colon + 1, bang);
                JarFile jarFile = new JarFile(jarFileName);
                Enumeration<JarEntry> entries = jarFile.entries();
                while (entries.hasMoreElements()) {
                    JarEntry e = entries.nextElement();
                    String jarEntryName = e.getName();
                    if (!jarEntryName.endsWith("/") &&
                        jarEntryName.startsWith(packageAsDirName)) {
                        list.add(jarEntryName);
                    }
                }
                break;
            default:
                throw new IllegalStateException(
                "Dunno what to do with URL " + url);
            }
        }
        return list.toArray(new String[] {});
    }

    public static void main(String[] args) throws IOException {
        String[] names = getPackageContent("com.darwinsys.io");
        for (String name : names) {
            System.out.println(name);
        }
        System.out.println("Done");
    }
}

请注意,如果在javasrc项目中运行此应用程序,它将两次列出演示包(com.darwinsys.io)的成员,因为它将在构建目录和 JAR 文件中找到它们。如果这是一个问题,请将List更改为Set(参见 Recipe 7.3)。

17.10 使用和定义注解

问题

你需要知道如何在代码中使用注解或定义自己的注解。

解决方案

在你的代码中使用@``AnnotationName来标注类、方法、字段等。使用@interface在与classinterface等相同级别定义注解。

讨论

注解是一种在源代码之外添加额外信息的方法。注解可以针对编译器或运行时检查。它们的语法在某种程度上模仿了 javadoc 注解(如文档注释内的@author@version)。注解是我所谓的类似类的东西(因此它们有首字母大写的名称),但在使用时前缀为@号(例如@Override)。你可以将它们放在类、方法、字段和少数其他位置;它们必须出现在它们注释的东西之前(忽略空格和注释)。在给定位置,给定注解只能出现一次(在 Java 8 或 9 中放宽了这一限制)。

作为编译时注解的一个例子,考虑覆盖时常见的错误:如 Example 17-14 所示,方法签名的小错误可能导致意外的重载。

Example 17-14. MyClass.java(为何我们需要注解的示例)
public class MyClass {

    public boolean equals(MyClass object2) {
        // compare, return boolean
    }
}

该代码在任何版本的 Java 上都能编译通过,但是它是错误的。equals()方法的标准契约(见 Recipe 8.1)要求其唯一参数为java.lang.Object类型。前述版本创建了一个意外的重载。因为equals()方法(及其伴侣方法hashCode();见 Recipe 8.1)的主要用途是在集合类(见 Chapter 7)中,这个重载方法永远不会被调用,导致代码无效,并且在SetMap中的操作是错误的。

解决方法非常简单:使用注解java.lang.Override,例如 Example 17-15,告知编译器注解的方法需要覆盖从超类型(如超类或接口)继承的方法。如果没有,则代码将无法编译通过。

Example 17-15. 带有 @Override 注解的 MyClass.java
public class MyClass {

    @Override
    public boolean equals(MyClass object2) {
        // compare, return boolean
    }
}

这个equals()版本虽然仍然是错误的,但会在编译时标记为错误,潜在地避免了大量的调试时间。在你自己的类上使用这种注解,在编写新代码时和维护代码库时都会有帮助;如果从超类中删除了一个方法,所有尝试覆盖它的子类 并且 带有@Override注解的方法将导致一个错误消息,让你能够删除一堆死代码。

注解的第二个主要用途是在运行时提供元数据。例如,Java 持久性 API(JPA,见https://darwinsys.com/db_in_java)使用其自己的一组来自javax.persistence包的注解来标记要加载和/或持久化的实体类。一个 JPA 实体类可能看起来像 Example 17-16。

Example 17-16. main/src/main/java/domain/Person.java(JPA 注解)
@Entity
public class Person {

    int id;
    protected String firstName;
    protected String lastName;

    public Person() {
        // required by JPA; must code it since we need 2-arg form.
    }

    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    @Id @GeneratedValue(strategy=GenerationType.AUTO, generator="my_poid_gen")
    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    @Column(name="surname")
    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

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

    @Transient /* synthetic: cannot be used in JPA queries. */
    public String getFullName() {
        StringBuilder sb = new StringBuilder();
        if (firstName != null)
            sb.append(firstName).append(' ');
        if (lastName != null)
            sb.append(lastName);
        if (sb.length() == 0)
            sb.append("NO NAME");
        return sb.toString();
    }
}

在类级别上的@Entity注解指示 JPA 将其视为要映射到数据库中的数据对象。@Id告知 JPA 这个id是主键属性,而@GeneratedValue告诉它如何为新创建的对象分配主键值。只有在关系数据库中的列名与基于属性的预期名称不同时,才需要@Column注解;在这种情况下,SQL 数据库设计者使用了surname,而 Java 开发者想使用lastName

我曾说过注解是类似类的东西,因此你可以自定义。这里的语法有点古怪;你使用@interface。据传说,开发这一特性的团队要么被告知不要,要么是因为在 Java SE 1.4 引入enum关键字时造成的麻烦而害怕引入新的关键字。又或许,他们只是想使用更符合注解使用情况的语法。无论如何,示例 17-17 是一个自定义注解的简单示例。

示例 17-17. 定义的简单注解
package lang;

public @interface MyToyAnnotation {
}

注解是类似类的东西,因此它们的命名应该相同——即以大写字母开头,并且如果是公共的,应存储在同名的源文件中(例如,MyToyAnnotation.java)。

使用javac编译示例 17-17,你会看到一个新的MyToyAnnotation.class文件。在示例 17-18 中,我们使用javap,标准的 JDK 类检查工具来检查它。

示例 17-18. 运行 javap 查看简单注解
$ javap lang.MyToyAnnotation
Compiled from "MyToyAnnotation.java"
public interface lang.MyToyAnnotation extends java.lang.annotation.Annotation {
}
$

正如它所说,注解在类文件格式中表示为只是扩展了Annotation接口的接口(回答显而易见的问题,你可以这样编写简单接口,但这真是个糟糕的主意)。在示例 17-19 中,我们快速看一下Annotation本身。

示例 17-19. 注解接口详解
$ javap java.lang.annotation.Annotation
Compiled from "Annotation.java"
public interface java.lang.annotation.Annotation {
  public abstract boolean equals(java.lang.Object);
  public abstract int hashCode();
  public abstract java.lang.String toString();
  public abstract java.lang.Class<? extends java.lang.annotation.Annotation>
    annotationType();
}
$

注解可以被设计成编译器只允许在代码中的某些点上使用。示例 17-20 是一个只能用于类或接口的注解。

示例 17-20. 类、接口等的样本注释
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}

@Target指定了注解可以使用的位置:ElementType.TYPE使其可用于类、接口、类似类的东西(如枚举),甚至是注解!要将其限制在仅注解使用上,有ElementType.ANNOTATION_TYPE。其他类型包括METHODFIELDCONSTRUCTORLOCAL_VARIABLEPACKAGEPARAMETER。因此,这个注解本身被两个@ANNOTATION_TYPE目标注解注释。

使用现有框架的注解需要查阅其文档。在运行时使用自定义注解需要使用反射 API,如示例 17-21 所示。

关于注解还有一件事需要注意,那就是它们可能具有属性。这些属性在注解源代码中定义为方法,但在使用注解时用作属性。Example 17-21 展示了一个具有此类属性的注解注解。

Example 17-21. main/src/main/java/lang/AnnotationDemo.java
/**
 * A sample annotation for types (classes, interfaces);
 * it will be available at run time.
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnotationDemo {
    public boolean fancy() default false;
    public int order() default 42;
}

/** A simple example of using the annotation */
@AnnotationDemo(fancy=true)
@Resource(name="Dumbledore")
class FancyClassJustToShowAnnotation {

    /** Print out the annotations attached to this class */
    public static void main(String[] args) {
        Class<?> c = FancyClassJustToShowAnnotation.class;
        System.out.println("Class " + c.getName() + " has these annotations:");
        for (Annotation a : c.getAnnotations()) {
            if (a instanceof AnnotationDemo) {
                AnnotationDemo ad = (AnnotationDemo)a;
                System.out.println("\t" +a +
                    " with fancy=" + ad.fancy() +
                    " and order " + ad.order());
            } else {
                System.out.println("\tSomebody else's annotation: " + a);
            }
        }
    }
}

AnnotationDemo 具有元注解 @Target(ElementType.TYPE),表明它可以注解用户定义的类型(例如类)。其他 ElementType 选项包括 METHODFIELDPARAMETER。如果需要多个选项,请使用数组初始化语法。

AnnotationDemo 还具有 @Retention(RetentionPolicy.RUNTIME) 注解,请求在运行时保留它。显然,任何在运行时被框架检查的注解都需要此注解。

这两个元注解通常用于运行时检查的用户定义注解。

FancyClassJustToShowAnnotation 展示了如何使用 AnnotationDemo 注解,以及一个标准的 Java 注解(@Resource 注解)。

参考 Recipe 17.11 以查看如何完整使用此机制的示例。

17.11 通过注解查找类似插件的类

问题

您希望执行类似插件的操作,但又不想使用显式的插件 API。

解决方案

为此目的定义一个注解,并使用它来标记插件类。

讨论

假设我们想建模 Java EE 标准中的 javax.annotations.Namedjavax.faces.ManagedBean 注解的工作方式;对于每个使用这些注解的类,将类名转换为类似实例的名称(例如,将首字母小写),并进行特殊处理。您可能希望像以下这样做:

  1. 获取给定包中的类列表(参见 Recipe 17.9)。

  2. 检查类是否已注解。

  3. 如果是这样,请保存名称和稍后使用的 Class 描述符。

这在 Example 17-22 中实现。

Example 17-22. main/src/main/java/reflection/PluginsViaAnnotations
/** Discover "plugins" or other add-in classes via Reflection using Annotations */
public class PluginsViaAnnotations {

    /**
 * Find all classes in the given package which have the given
 * class-level annotation class.
 */
    public static List<Class<?>> findAnnotatedClasses(String packageName,
        Class<? extends Annotation> annotationClass) throws Exception {

        List<Class<?>> ret = new ArrayList<>();
        String[] clazzNames = ClassesInPackage.getPackageContent(packageName);
        for (String clazzName : clazzNames) {
            if (!clazzName.endsWith(".class")) {
                continue;
            }
            clazzName = clazzName.replace('/', '.').replace(".class", "");
            Class<?> c = null;
            try {
                c = Class.forName(clazzName);
            } catch (ClassNotFoundException ex) {
                System.err.println("Weird: class " + clazzName +
                    " reported in package but gave CNFE: " + ex);
                continue;
            }
            if (c.isAnnotationPresent(annotationClass) &&
                    !ret.contains(c))
                    ret.add(c);

        }
        return ret;
    }

我们可以进一步支持特定方法注解,类似于 javax.annotations.PostCreate,用于装饰在框架实例化 bean 后调用的方法。我们的流程现在类似于这样,并且代码显示在 Example 17-23 中:

  1. 获取给定包中的类列表(参见 Recipe 17.9)。

  2. 如果使用类级别的注解,请检查该类是否已注解。

  3. 如果此类仍然感兴趣,请获取其方法列表。

  4. 对于每个方法,查看其是否包含给定的特定方法注解。

  5. 如果是这样,请将该类和方法添加到可调用方法列表中。

Example 17-23. main/src/main/java/reflection/PluginsViaAnnotations(查找带注解方法)
    /**
 * Find all classes in the given package which have the given
 * method-level annotation class on at least one method.
 */
    public static List<Class<?>> findClassesWithAnnotatedMethods(String packageName,
            Class<? extends Annotation> methodAnnotationClass) throws Exception {
        List<Class<?>> ret = new ArrayList<>();
        String[] clazzNames = ClassesInPackage.getPackageContent(packageName);
        for (String clazzName : clazzNames) {
            if (!clazzName.endsWith(".class")) {
                continue;
            }
            clazzName = clazzName.replace('/', '.').replace(".class", "");
            Class<?> c = null;
            try {
                c = Class.forName(clazzName);
                // System.out.println("Loaded " + c);
            } catch (ClassNotFoundException ex) {
                System.err.println("Weird: class " + clazzName +
                    " reported in package but gave CNFE: " + ex);
                continue;
            }
            for (Method m : c.getDeclaredMethods()) {
                // System.out.printf("Class %s Method: %s\n",
                //     c.getSimpleName(), m.getName());
                if (m.isAnnotationPresent(methodAnnotationClass) &&
                        !ret.contains(c)) {
                    ret.add(c);
                }
            }
        }
        return ret;
    }

参见

第 17.10 节及本章剩余内容。

17.12 程序:交叉引用

你可能见过那些完全由 Java API 列表组成的其他 Java 书籍,这些书籍针对 JDK 的某个版本。我不认为你会认为这些作品的作者坐下来从头开始键入整个内容。作为一个程序员,希望你意识到,必须有一种方法从 Java 获取这些信息。但你可能没有意识到这是多么容易!如果你忠实地阅读了这一章,现在你知道有一种真正的方法:让计算机来走这条路。示例 17-24 是一个将大部分技术结合在一起的程序。这个版本生成了一个交叉引用列表,但通过重写最后几个方法,你可以轻松地将其转换为以任何你喜欢的格式打印信息,包括 API 参考书。你需要处理这些或那些发布软件的细节——FrameMaker、troff、T[E]X,或者其他——但这是简单的部分。

此程序比第 17.8 节的MyJavaP更充分地利用了反射 API。它还使用了java.util.zip类(参见第 10.15 节),以破解包含 API 类文件的 JAR 存档。加载并列出存档中找到的每个类文件;列表部分类似于MyJavaP

示例 17-24. main/src/main/java/reflection/CrossRef.java
public class CrossRef extends APIFormatter {

    /** Simple main program, construct self, process each .ZIP file
 * found in CLASSPATH or in argv.
 */
    public static void main(String[] argv) throws IOException {
        CrossRef xref = new CrossRef();
        xref.doArgs(argv);
    }

    /**
 * Print the fields and methods of one class.
 */
    protected void doClass(Class<?> c) {
        startClass(c);
        try {
            Field[] fields = c.getDeclaredFields();
            Arrays.sort(fields, new Comparator<Field>() {
                public int compare(Field o1, Field o2) {
                    return o1.getName().compareTo(o2.getName());
                }
            });
            for (int i = 0; i < fields.length; i++) {
                Field field = (Field)fields[i];
                if (!Modifier.isPrivate(field.getModifiers()))
                    putField(field, c);
                // else System.err.println("private field ignored: " + field);
            }

            Method methods[] = c.getDeclaredMethods();
            Arrays.sort(methods, new Comparator<Method>() {
                public int compare(Method o1, Method o2) {
                    return o1.getName().compareTo(o2.getName());
                }
            });
            for (int i = 0; i < methods.length; i++) {
                if (!Modifier.isPrivate(methods[i].getModifiers()))
                    putMethod(methods[i], c);
                // else System.err.println("pvt: " + methods[i]);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        endClass();
    }

    /** put a Field's information to the standard output.  */
    protected void putField(Field fld, Class<?> c) {
        println(fld.getName() + " field " + c.getName() + " ");
    }

    /** put a Method's information to the standard output.  */
    protected void putMethod(Method method, Class<?> c) {
        String methName = method.getName();
        println(methName + " method " + c.getName() + " ");
    }

    /** Print the start of a class. Unused in this version,
 * designed to be overridden */
    protected void startClass(Class<?> c) {
    }

    /** Print the end of a class. Unused in this version,
 * designed to be overridden */
    protected void endClass() {
    }

    /** Convenience routine, short for System.out.println */
    protected final void println(String s) {
        System.out.println(s);
    }
}

你可能已经注意到了startClass()endClass()这两个方法,它们是空的。这些方法是为了在每个类的开始和结束时方便子类化而设计的占位符。一个例子可能是一个高级文本格式化应用程序,在每个类的开头输出粗体标题。另一个例子可能是 XML,你需要在每个类的前面写一个像<class>这样的标签,以及类的末尾写一个</class>。示例 17-25 是一个特定于 XML 的子类,为每个字段和方法生成(有限的)XML。

示例 17-25. main/src/main/java/reflection/CrossRefXML.java
public class CrossRefXML extends CrossRef {

    public static void main(String[] argv) throws IOException {
        CrossRef xref = new CrossRefXML();
        xref.doArgs(argv);
    }

    /** Print the start of a class.
 */
    protected void startClass(Class<?> c) {
        println("<class><classname>" + c.getName() + "</classname>");
    }

    protected void putField(Field fld, Class<?> c) {
        println("<field>" + fld + "</field>");
    }

    /** put a Method's information to the standard output.
 * Marked protected so you can override it (hint, hint).
 */
    protected void putMethod(Method method, Class<?> c) {
        println("<method>" + method + "</method>");
    }

    /** Print the end of a class.
 */
    protected void endClass() {
        println("</class>");
    }
}

顺便说一句,如果你用这些方法出书赚大钱了,“记得,记得我!”

参见

我们还没有探讨反射或ClassLoader机制的方方面面,但现在你应该对其工作原理有一个基本的了解。

可能最重要的遗漏是 SecurityManagerProtectionDomain。在给定的 JVM 实例中只能安装一个 SecurityManager(例如,防止恶意代码提供自己的 SecurityManager!)。例如,运行旧的 Java Applet API 的浏览器提供的 SecurityManager 比标准的要严格得多。编写这样的 SecurityManager 留给读者作为一个重要练习,特别是计划从互联网加载类的任何人!(有关安全管理器和 Java 安全 API 的更多信息,请参阅 Java Security,作者 Scott Oaks(O'Reilly)。ProtectionDomain 可以通过 ClassLoader 提供给指定类运行所需的所有权限。

我还没有深入探讨 JVM 中的许多主题;可以参考(有些过时的)O'Reilly 图书 Java Virtual Machine,作者 Troy Downing 和 Jon Meyer,以及 Java Language Reference,作者 Mark Grand。你也可以阅读 Sun/Oracle 的 Java 语言规范JVM 规范 文档(随新版本更新,可在线获取),享受和提高阅读的乐趣!

Apache 软件基金会维护了一系列非常有用的软件包,可以免费获取和使用。源代码始终可以在其网站上免费获取。你可能想调查的两个包括 Commons BeanUtils 和 Byte Code Engineering Library (BCEL)。Commons BeanUtils 声称提供了一些对反射 API 更易使用的包装器。BCEL 是一个第三方工具包,用于构建和操作字节码类文件。由 Markus Dahm 编写的 BCEL 已成为 Apache Commons 项目 的一部分。

第十八章:使用 Java 与其他语言

18.0 介绍

Java 有几种运行其他语言编写的程序的方法。你可以使用Runtime.exec()来调用已编译的程序或可执行脚本,正如我将在 Recipe 18.1 中描述的那样。这里涉及系统依赖性,因为只能在编译为其操作系统的外部应用程序下运行它们。或者,你可以使用javax.script调用多种脚本语言(或动态语言),如:awk、bsh、Clojure、Ruby、Perl、Python、Scala,如 Recipe 18.3 所示。或者你可以通过 Java 的本地代码机制下降到 C 级别,并调用用 C/C++编写的编译函数,参见 Recipe 18.6。从本地代码,你可以调用几乎任何语言编写的函数。更不用说你可以通过套接字联系任何语言编写的程序(参见 Chapter 13),使用 HTTP 服务(参见 Chapter 13),或者使用 Java 客户端在 RMI 或 CORBA 客户端中使用各种语言。

JVM 还有许多其他语言,包括以下几种:

  • BeanShell,Java 的一般脚本语言。

  • Groovy是一种基于 Java 的脚本语言,为 Java 语言生态系统中的闭包使用开创了先河。它还有一个快速开发的 Web 包叫做Grails和一个叫做 Gradle 的构建工具(参见 Recipe 1.8)。Gradle 也被用作现代 Android 开发中的构建工具。

  • Jython,Python 的完整 Java 实现。

  • JRuby,Ruby 语言的完整 Java 实现。

  • Scala,一个声称提供“功能和面向对象语言的最佳结合”的 JVM 语言。

  • Clojure,一种主要用于 JVM 的函数式Lisp-1方言。

  • Renjin(发音为“R engine”),一个相当完整的开源 R 统计包克隆,具有扩展到云端的能力。参见 Recipe 11.5 中使用 Renjin 的示例。

这些都是以 JVM 为中心的,并且有些可以直接从 Java 调用脚本,反之亦然,而不使用javax.script。可以在Wikipedia上找到这些语言的列表。

18.1 在 Java 中运行外部程序

问题

你想在 Java 程序中运行外部程序。

解决方案

使用java.lang.Runtime类中的exec()方法之一。或者设置一个ProcessBuilder并调用其start()方法。

讨论

Runtime类中的exec()方法允许你运行外部程序。你给出的命令行通过简单的StringTokenizer(参见 Recipe 3.1)分解为字符串,并传递给操作系统的“执行程序”系统调用。例如,这里是一个使用exec()运行* kwrite 的简单程序,一个带窗口的文本编辑器程序。¹ 在 Windows 上,你可能需要将名称更改为notepadwordpad,可能包括完整路径名,例如,c:/windows/notepad.exe*(你也可以使用反斜杠,但要小心要双倍,因为反斜杠在 Java 字符串中是特殊字符):

public class ExecDemoSimple {
    public static void main(String av[]) throws Exception {

        // Run the "notepad" program or a similar editor
        Process p = Runtime.getRuntime().exec("kwrite");

        p.waitFor();
    }
}

当你编译并运行它时,适当的编辑器窗口会出现:

$ javac -d . ExecDemoSimple.java
$ java otherlang.ExecDemoSimple # causes a KWrite window to appear.
$

这个版本的exec()假设路径名不包含空格,因为空格会破坏StringTokenizer的正常操作。为了克服这个潜在问题,使用exec()的一个重载形式,接受一个字符串数组作为参数。Example 18-1 运行 Windows 或 Unix 版本的 Firefox 网络浏览器,假设 Firefox 安装在默认目录(或另一个在你的PATH上的目录)。它传递一个帮助文件的名称作为参数,提供一种原始的帮助机制,如 Figure 18-1 所示。

例子 18-1. main/src/main/java/otherlang/ExecDemoNS.java
public class ExecDemoNS extends JFrame {
    private static final String BROWSER = "firefox";

    Logger logger = Logger.getLogger(ExecDemoNS.class.getSimpleName());

    /** The name of the help file. */
    protected final static String HELPFILE = "./help/index.html";

    /** A stack of process objects; each entry tracks one running process */
    Stack<Process> pStack = new Stack<>();

    /** main - instantiate and run */
    public static void main(String av[]) throws Exception {
        String program = av.length == 0 ? BROWSER : av[0];
        new ExecDemoNS(program).setVisible(true);
    }

    /** The path to the binary executable that we will run */
    protected static String program;

    /** Constructor - set up strings and things. */
    public ExecDemoNS(String program) {
        super("ExecDemo: " + program);
        this.program = program;

        Container cp = getContentPane();
        cp.setLayout(new FlowLayout());
        JButton b;
        cp.add(b=new JButton("Exec"));
        b.addActionListener(e -> runProgram());
        cp.add(b=new JButton("Wait"));
        b.addActionListener(e -> doWait());
        cp.add(b=new JButton("Exit"));
        b.addActionListener(e -> System.exit(0));
        pack();
    }

    /** Start the help, in its own Thread. */
    public void runProgram() {

        new Thread() {
            public void run() {

                try {
                    // Get a "file:" URL for the Help File
                    URL helpURL = this.getClass().getClassLoader().
                        getResource(HELPFILE);

                    // Start the external browser from the Java Application.

                    String osname = System.getProperty("os.name");
                    String run;
                    if ("Mac OS X".equals(osname)) {
                        run = "open -a " + program;
                        // "if" allows for other OSes needing special handling
                    } else {
                        run = program;
                    }

                    pStack.push(Runtime.getRuntime().exec(run + " " + helpURL));

                    logger.info("In main after exec " + pStack.size());

                } catch (Exception ex) {
                    JOptionPane.showMessageDialog(ExecDemoNS.this,
                        "Error" + ex, "Error",
                        JOptionPane.ERROR_MESSAGE);
                }
            }
        }.start();

    }

    public void doWait() {
        if (pStack.size() == 0) {
            logger.info("Nothing to wait for.");
            return;
        }
        logger.info("Waiting for process " + pStack.size());
        try {
            Process p = pStack.pop();
            p.waitFor();
            // wait for process to complete
            // (may not work as expected for some old Windows programs)
            logger.info("Process " + p + " is done.");
        } catch (Exception ex) {
            JOptionPane.showMessageDialog(this,
                "Error" + ex, "Error",
                JOptionPane.ERROR_MESSAGE);
        }
    }

}

jcb4 1801

图 18-1. ExecDemoNS 在运行中

一个更新的类,ProcessBuilder,替代了大多数非平凡使用Runtime.exec()的情况。这个ProcessBuilder使用通用集合允许你修改或替换环境,如示例 Example 18-2 所示。

例子 18-2. main/src/main/java/otherlang/ProcessBuilderDemo.java
        List<String> command = new ArrayList<>();            ![1](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/1.png)
        command.add("notepad");
        command.add("foo.txt");
        ProcessBuilder builder = new ProcessBuilder(command);![2](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/2.png)
        builder.environment().put("PATH",
                "/windows;/windows/system32;/winnt");        ![3](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/3.png)
        final Process godot = builder.directory(
            new File(System.getProperty("user.home"))).      ![4](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/4.png)
            start();
        System.err.println("Waiting for Godot");             ![5](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/5.png)
        godot.waitFor();                                     ![6](https://gitee.com/OpenDocCN/ibooker-java-zh/raw/master/docs/java-cb-4e/img/6.png)

1

设置命令行参数列表:编辑器程序名称和文件名。

2

使用这个来开始配置ProcessBuilder

3

配置构建器的环境到一组常见的 MS Windows 目录。

4

设置初始目录为用户的主目录,并启动进程!

5

我一直想在代码中使用这行。

6

等待我们小戏的结束。

关于ProcessBuilder更多内容,请参阅java.lang.ProcessBuilder的 javadoc。

18.2 运行程序并捕获其输出

问题

想要运行一个程序,同时捕获其输出。

解决方案

使用Process对象的getInputStream();读取并复制内容到System.out或任何你想要的地方。

讨论

最初的标准输出和标准错误的概念是它们总是连接到终端;这个概念来自一个早期的时代,当时几乎所有的计算机用户都在命令行上工作。如今,一个程序的标准输出和错误输出并不总是自动出现在任何地方。可以说,应该有一种自动的方法使这种情况发生。但目前,您需要添加几行代码来获取程序的输出并打印它:

public class ExecDemoLs {

    private static Logger logger =
        Logger.getLogger(ExecDemoLs.class.getSimpleName());

    /** The program to run */
    public static final String PROGRAM = "ls"; // "dir" for Windows
    /** Set to true to end the loop */
    static volatile boolean done = false;

    public static void main(String argv[]) throws IOException {

        final Process p;         // Process tracks one external native process
        BufferedReader is;    // reader for output of process
        String line;

        p = Runtime.getRuntime().exec(PROGRAM);

        logger.info("In Main after exec");

        // Optional: start a thread to wait for the process to terminate.
        // Don't just wait in main line, but here set a "done" flag and
        // use that to control the main reading loop below.
        Thread waiter = new Thread() {
            public void run() {
                try {
                    p.waitFor();
                } catch (InterruptedException ex) {
                    // OK, just quit.
                    return;
                }
                System.out.println("Program terminated!");
                done = true;
            }
        };
        waiter.start();

        // getInputStream gives an Input stream connected to
        // the process p's standard output (and vice versa). We use
        // that to construct a BufferedReader so we can readLine() it.
        is = new BufferedReader(new InputStreamReader(p.getInputStream()));

        while (!done && ((line = is.readLine()) != null))
            System.out.println(line);

        logger.info("In Main after EOF");

        return;
    }
}

这种情况如此常见,以至于我将其封装为一个名为ExecAndPrint的类,它是我的com.darwinsys.lang包的一部分。 ExecAndPrint有几种重载形式的run()方法(有关详细信息,请参阅文档),但它们都至少需要一个命令,并且可选地需要一个输出文件,用于将命令的输出写入。 示例 18-3 显示了其中一些方法的代码。

示例 18-3. darwinsys-api/src/main/java/com/darwinsys/lang/ExecAndPrint.java
    /** Need a Runtime object for any of these methods */
    protected final static Runtime r = Runtime.getRuntime();

    /** Run the command given as a String, output to System.out
 * @param cmd The command
 * @return The command's exit status
 * @throws IOException if the command isn't found
 */
    public static int run(String cmd) throws IOException {
        return run(cmd, new OutputStreamWriter(System.out));
    }

    /** Run the command given as a String, output to "out"
 * @param cmd The command and list of arguments
 * @param out The output file
 * @return The command's exit status
 * @throws IOException if the command isn't found
 */
    public static int run(String cmd, Writer out) throws IOException {

        Process p = r.exec(cmd);

        FileIO.copyFile(new InputStreamReader(p.getInputStream()), out, true);
        try {
            p.waitFor();    // wait for process to complete
        } catch (InterruptedException e) {
            return -1;
        }
        return p.exitValue();
    }

作为直接使用exec()ExecAndPrint的简单示例,我将创建三个临时文件,列出它们(目录列表),然后删除它们。当我运行ExecDemoFiles程序时,它会列出它已创建的三个文件:

-rw-------  1 ian  wheel  0 Jan 29 14:29 file1
-rw-------  1 ian  wheel  0 Jan 29 14:29 file2
-rw-------  1 ian  wheel  0 Jan 29 14:29 file3

其源代码位于示例 18-4。

示例 18-4. main/src/main/java/otherlang/ExecDemoFiles.java
        // Get and save the Runtime object.
        Runtime rt = Runtime.getRuntime();

        // Create three temporary files (the slow way!)
        rt.exec("mktemp file1");
        rt.exec("mktemp file2");
        rt.exec("mktemp file3");

        // Run the "ls" (directory lister) program
        // with its output sent into a file
        String[] args = { "ls", "-l", "file1", "file2", "file3" };
        ExecAndPrint.run(args);

        rt.exec("rm file1 file2 file3");

当创建进程的 Java 程序退出或崩溃时,并不一定会销毁进程。简单的文本程序会销毁,但像kwrite Netscape,甚至是基于 Java 的JFrame应用程序等基于窗口的程序不会。例如,我们的ExecDemoNS程序启动了 Netscape,当单击ExecDemoNS的退出按钮时,ExecDemoNS退出但 Netscape 仍在运行。如果你想要确保一个进程已经完成,该怎么办?Process对象有一个waitFor()方法,让你可以这样做,并且一个exitValue()方法,告诉你进程的返回码。最后,如果你希望强制终止另一个进程,你可以使用Process对象的destroy()方法,该方法不接受参数并且不返回值。示例 18-5 是ExecDemoWait,一个运行你在命令行中命名的任何程序(以及参数),捕获程序的标准输出,并等待程序终止的程序。

示例 18-5. main/src/main/java/otherlang/ExecDemoWait.java
        // A Runtime object has methods for dealing with the OS
        Runtime r = Runtime.getRuntime();
        Process p;         // Process tracks one external native process
        BufferedReader is;    // reader for output of process
        String line;

        // Our argv[0] contains the program to run; remaining elements
        // of argv contain args for the target program. This is just
        // what is needed for the String[] form of exec.
        p = r.exec(argv);

        System.out.println("In Main after exec");

        // getInputStream gives an Input stream connected to
        // the process p's standard output. Just use it to make
        // a BufferedReader to readLine() what the program writes out.
        is = new BufferedReader(new InputStreamReader(p.getInputStream()));

        while ((line = is.readLine()) != null)
            System.out.println(line);

        System.out.println("In Main after EOF");
        System.out.flush();
        try {
            p.waitFor();    // wait for process to complete
        } catch (InterruptedException e) {
            System.err.println(e);    // "Can't Happen"
            return;
        }
        System.err.println("Process done, exit status was " + p.exitValue());

另请参阅

你通常不会以这种方式使用任何形式的exec()来从一个 Java 程序运行另一个 Java 程序;相反,你可能会将其创建为同一进程中的一个线程,因为这通常要快得多(Java 解释器已经启动运行,为什么要等待另一个副本启动?)。参见第十六章。

在构建工业级应用程序时,请注意 Java API 文档中关于Process类的警告性说明,因为操作系统的缓冲区不足可能导致部分 I/O 丢失的危险。

18.3 通过 javax.script 调用其他语言

问题

您希望从 Java 程序中调用用其他语言编写的脚本,在 JVM 中运行,并能直接传递变量到/从其他语言。

解决方案

如果您想要的脚本是用支持的二十多种语言之一编写的,请使用 javax.script。这些语言包括 awk、Perl、Python、Ruby、BeanShell、PNuts、Ksh/Bash、R(Renjin)和几个 JavaScript 的实现。

讨论

使用这个 API 的一个最初的任务是找出安装的脚本引擎,然后选择一个可用的引擎。示例 18-6 中的 ScriptEnginesDemo 程序列出了已安装的引擎,并运行了一个简单的脚本,使用默认语言 ECMAScript(又名 JavaScript)。

示例 18-6. main/src/main/java/otherlang/ScriptEnginesDemo.java
public class ScriptEnginesDemo {

    public static void main(String[] args) throws ScriptException {
        ScriptEngineManager scriptEngineManager = new ScriptEngineManager();

        // Print list of supported languages
        scriptEngineManager.getEngineFactories().forEach(factory ->
            System.out.println(factory.getLanguageName()));

        // Run a script in the JavaScript language
        String lang = "JavaScript";
        ScriptEngine engine =
            scriptEngineManager.getEngineByName(lang);
        if (engine == null) {
            System.err.println("Could not find engine");
            return;
        }
        engine.eval("print(\"Hello from " + lang + "\");");
    }
}

示例 18-7 是一个非常简单的示例,展示了如何使用 javax.scripting 从 Java 调用 Python。我们知道要使用的脚本引擎的名称是 Python。我们将使用在虚拟机中的实现,称为 jython,最初称为 JPython,但因商标问题而更改。一旦将 jython-standalone-2.nnn.jar 加入到我们的 CLASSPATH 中,脚本引擎就会自动检测到。以防失败,我们会打印一个详细的消息,包括可用引擎的列表。

示例 18-7. main/src/main/java/otherlang/PythonFromJava.java
/**
 * demo using Python (jython) to get a Java variable, print, and change it.
 * @author Ian Darwin
 */
public class PythonFromJava {
    private static final String PY_SCRIPTNAME = "pythonfromjava.py";

    public static void main(String[] args) throws Exception {
        ScriptEngineManager scriptEngineManager = new ScriptEngineManager();

        ScriptEngine engine = scriptEngineManager.getEngineByName("python");
        if (engine == null) {
            final String message =
                "Could not find 'python' engine; add its jar to CLASSPATH";
            System.out.println(message);
            System.out.println("Available script engines are: ");
            scriptEngineManager.getEngineFactories().forEach(factory ->
                System.out.println(factory.getLanguageName()));
            throw new IllegalStateException(message);
        }

        final Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE);
        bindings.put("meaning", 42);

        // Let's run a python script stored on disk (well, on classpath):
        InputStream is =
            PythonFromJava.class.getResourceAsStream("/" + PY_SCRIPTNAME);
        if (is == null) {
            throw new IOException("Could not find file " + PY_SCRIPTNAME);
        }
        engine.eval(new InputStreamReader(is));
        System.out.println("Java: Meaning is now " + bindings.get("meaning"));
    }
}

参见

在 Oracle 拆除 java.net 之前,曾经有一个列出许多语言的列表(请参阅 此存档列表;链接已经失效,但显示了可用的语言范围)。当时,您可以从该站点下载脚本引擎。不幸的是,我不知道目前有一个官方的引擎列表。然而,作为脚本项目的一部分维护的列表可以在一个非官方的源代码库中找到,通过查看 https://github.com/scijava/javax-scripting,在理论上应该能够构建您想要的列表。此项目外的其他十几个引擎由其他人维护;例如,有一个来自 Google CodePerl5 脚本引擎。

还有一个 Java 兼容的脚本语言列表(不一定全部使用 javax.script)。

你可以自行编写脚本引擎;参见我的介绍 https://darwinsys.com/java/scriptengines.html

18.4 使用 GraalVM 混合语言

问题

GraalVM 的目标是多语言的,您希望在 VM 中使用不同的语言。

解决方案

使用 gu(graal 实用工具)安装额外的语言包并调用其他语言。

讨论

虽然 GraalVM 自称能够支持各种编程语言,但目前支持的语言数量虽小,但正在增长。让我们尝试从 Java 内部调用 Python 代码。假设您已经按照 Recipe 1.2 的说明安装了 Graal 本身,您的可执行路径上应该有gu,因此请尝试以下操作:

$ gu install python
Downloading: Component catalog from www.graalvm.org
Processing component archive: Graal.Python
Downloading: Component python: Graal.Python  from github.com
Installing new component: Graal.Python (org.graalvm.python, version 19.2.0.1)

IMPORTANT NOTE:
---------------
Set of GraalVM components that provide language implementations have changed.
  The Polyglot native image and polyglot native C library may be out of sync:
- new languages may not be accessible
- removed languages may cause the native binary to fail on missing resources
  or libraries.
To rebuild and refresh the native binaries, use the following command:
      Library/Java/JavaVirtualMachines/graalvm-ce-19.2.0.1/Contents/Home/bin/gu
      rebuild-images

You may need to install "native-image" component which provide the rebuild tools.

然后可以使用示例 18-8 中的代码。

示例 18-8. graal/src/JavaCallPython.java
import java.io.*;
import java.util.stream.*;
import org.graalvm.polyglot.*;

/**
 * GraalVM polyglot: calling Python from Java/
 */
// tag::main[]
public class JavaCallPython {

    public static void main(String[] args) throws java.io.IOException {

         try (Context context = Context.create("jython")) {
            Value result = context.execute("2 + 2");
            System.out.println(result.asString());
        }
    }
}
// end::main[]

18.5 将 Java 和 Perl 结合起来

问题

您希望从 Perl 调用 Java,或者反过来。

解决方案

要从 Perl 调用 Java,请使用 Perl 的Inline::Java模块。要反过来——从 Java 调用 Perl——请使用javax.script,就像在 Recipe 18.3 中所示。

讨论

Perl 通常被称为一种粘合语言,可以用来将软件世界的各种部分组合在一起。此外,它还是一个用于创建软件的完整语言。大量的扩展模块提供了就绪运行的解决方案,适用于相当多样化的问题,大多数这些模块可以从 CPAN(综合 Perl 存档网络)免费获取。另外,作为一种脚本语言,它非常适合快速原型设计。然而,尽管 Perl 确实可以构建图形用户界面,但这并不是该语言的强项之一。因此,您可能希望使用 Java Swing 构建 GUI,并同时重用在 Perl 中实现的业务逻辑。

幸运的是,在众多的 CPAN 模块中,Inline::Java使得 Perl 和 Java 的集成变得十分简便。首先假设您希望从 Perl 调用 Java。为了业务逻辑,我选择了一个 CPAN 模块,用于测量两个字符串的相似度(所谓的Levenshtein 编辑距离)。示例 18-9 展示了完整的源代码。您至少需要使用模块Inline::Java的 0.44 版本;早期版本不正确地支持了线程应用程序,因此不支持使用 Swing。

以这种方式使用该模块要求在 Perl 脚本中使用特殊分隔符包含 Java 源码,如示例 18-9 所示。

示例 18-9. Swinging.pl
#! /usr/bin/perl
# Calling Java from Perl

use strict;
use warnings;

use Text::Levenshtein qw( );
  # Perl module from CPAN to measure string similarity

use Inline 0.44 "JAVA" =&gt; "DATA";  # pointer to the Inline java source
use Inline::Java qw(caught);  # helper function to determine exception type

my $show = new Showit;     # construct Java object using Perl syntax
$show-&gt;show("Just another Perl hacker");            # call method on that object

eval {
  # Call a method that will call back to Perl;
  # catch exceptions, if any.
  print "matcher: ", $show-&gt;match("Japh", shift||"Java"),
  " (displayed from Perl)\n";
};
if ($@) {
  print STDERR "Caught:", caught($@), "\n";
  die $@ unless caught("java.lang.Exception");
  print STDERR $@-&gt;getMessage( ), "\n";
}

__END_  _

__JAVA_  _
// Java starts here
import javax.swing.*;
import org.perl.inline.java.*;

class Showit extends InlineJavaPerlCaller {
  // extension only neeeded if calling back into Perl

  /** Simple Java class to be called from Perl, and to call back to Perl
   */
  public Showit( ) throws InlineJavaException { }

  /** Simple method */
  public void show(String str) {
    System.out.println(str + " inside Java");
  }

  /** Method calling back into Perl */
  public int match(String target, String pattern)
      throws InlineJavaException, InlineJavaPerlException {

    // Calling a function residing in a Perl Module
    String str = (String)CallPerl("Text::Levenshtein", "distance",
          new Object [] {target, pattern});

    // Show result
    JOptionPane.showMessageDialog(null, "Edit distance between '" + target +
        "' and '" + pattern + "' is " + str,
        "Swinging Perl", JOptionPane.INFORMATION_MESSAGE);
    return Integer.parseInt(str);
  }

}

由于使用了Text::LevenshteinInline::Java模块,您需要安装这些模块。以下是标准的安装方法:

$ perl -MCPAN -e shell
> install Text::Levenshtein
> install Inline::Java
> quit

在某些系统上可能会有特定于操作系统的模块;例如,在 OpenBSD Unix 上,是这样的:

$ doas pkg_add p5-Text-LevenshteinXS

在一个简单的 Perl+Java 程序中,你甚至不需要编写单独的 Java 源文件:你可以将所有代码,无论是 Perl 还是 Java,都放在一个单独的文件中。你不需要编译任何东西,只需键入以下命令来执行它:

perl Swinging.pl

(您也可以添加一个字符串参数。)稍作处理后,一个 Java 消息框弹出,告诉您JaphJava之间的距离为 2。与此同时,您的控制台显示字符串“Just another Perl hacker inside Java。”当您关闭消息框时,您将得到最终结果“matcher: 2(从 Perl 显示)。”

在这期间,你的 Perl 程序通过调用其构造函数创建了 Java 类 Showit 的一个实例。然后它调用该对象的 show() 方法在 Java 中显示一个字符串。然后它继续调用 match() 方法,但这次发生了更复杂的事情:Java 代码回调 Perl,访问模块 Text::Levenshteindistance 方法,并将两个字符串作为参数传递给它。它接收结果,在消息框中显示它,最后,为了保险起见,将其返回给调用它的 Perl 主程序。

顺便说一句,eval { } 块是捕获异常的 Perl 方法。在这种情况下,异常是从 Java 内部抛出的。

如果重新启动程序,您会注意到启动时间大大缩短,这总是一个好消息。为什么会这样?因为在第一次调用时,Inline::Java 拆分输入,预编译了 Java 部分,并将其保存到磁盘上(通常在名为 _Inline 的子目录中)。在后续调用中,它只需确保 Java 源码没有更改,然后调用已经存在于磁盘上的类文件。(当然,如果您偷偷改变了 Java 代码,它会像自动魔术般重新编译。)然而,在幕后,还发生了更奇怪的事情。当执行 Perl 脚本时,会构建并启动一个 Java 服务器,用户毫不知情,Perl 部分和 Java 部分通过 TCP 套接字进行通信(见 第十三章)。

以一种可移植的方式将 Perl 和 Java 这样两种平台无关的语言结合在一起,可以避开许多可移植性问题。在分发内联应用程序时,确保不仅提供源文件,还提供 _Inline 目录的内容。(建议在分发之前清除该目录并重新构建所有内容;否则,留在那里的旧编译版本可能会进入分发版。)每台目标机器都需要重复执行 Inline::Java 的魔法步骤,这需要一个 Java 编译器。在任何情况下,都必须安装 Inline::Java 模块。

因为 Perl 拥有 Inline 模块,可以用于许多其他语言(如普通的 C 语言,但也包括像 Befunge 这样的异国语言),所以你甚至可以考虑使用 Perl 作为这些其他语言与 Java 之间或分别之间互操作的粘合剂。我相信你会在解决这些交互的复杂性时度过许多愉快的时光。

参见

您可以在 CPAN 上找到有关 Inline::Java 的完整信息,或者在模块本身安装时一同安装的 POD(Plain Old Documentation)中找到。

18.6 通过本地代码调用其他语言

问题

如果你希望从 Java 中调用本地的 C/C++ 函数,无论是为了提高效率还是访问硬件或特定系统功能。

解决方案

使用 JNI(Java Native Interface)。或者使用 GraalVM。

讨论

Java 允许你将本地或编译后的代码加载到你的 Java 程序中。为什么你要做这样的事情呢?最好的理由可能是为了访问依赖于操作系统的功能,或者是访问用另一种语言编写的现有代码。一个不太好的理由可能是速度:本地代码有时可能比 Java 运行得更快,尽管随着计算机变得更快、更多核心,这变得不那么重要了。与 Java 中的其他一切一样,本地代码机制受到安全限制;例如,小程序不被允许访问本地代码。

本地代码语言绑定是为使用 C 或 C++ 编写的代码定义的。如果你需要访问除 C/C++ 之外的其他语言,编写一些 C/C++ 并让它通过任何由你的操作系统定义的机制控制其他函数或应用程序。

由于诸如解释头文件和分配处理器的通用寄存器等系统相关特性,你的本地代码可能需要由用于编译你平台的 Java 运行时的相同的 C 编译器来编译。例如,在 Solaris 上,你可以使用 SunPro C 或者也许是 gcc。在 Win32 平台上,使用 Microsoft Visual C++ 版本 4.x 或更高版本(32 位)。对于 Linux 和 macOS,你应该能够使用提供的基于 gcc 的编译器。对于其他平台,请参阅你的 Java 供应商的文档。

还要注意,本节中的细节适用于 Java 1.1 及更高版本的 Java Native Interface (JNI),与 1.0 版本以及 Microsoft 的本地接口有一些细节上的不同。

第一步是编写调用本地方法的 Java 代码。为此,请使用关键字 native 表示该方法是本地方法,并提供一个静态代码块,使用 System.loadLibrary() 加载你的本地方法。(动态加载模块在第 5 步创建。)静态块在包含它们的类加载时执行;在这里加载本地代码可以确保在需要时它在内存中!

你的本地代码可能会修改的对象变量应该带有 volatile 修饰符。示例 18-10 中显示的 HelloJni.java 文件是一个很好的起点。

示例 18-10. main/src/main/java/jni/HelloJni.java
/**
 * A trivial class to show Java Native Interface 1.1 usage from Java.
 */
public class HelloJni {
  int myNumber = 42; // used to show argument passing

  // declare native class
  public native void displayHelloJni();

  // Application main, call its display method
  public static void main(String[] args) {
    System.out.println("HelloJni starting; args.length="+
                       args.length+"...");
    for (int i=0; i<args.length; i++)
                       System.out.println("args["+i+"]="+args[i]);
    HelloJni hw = new HelloJni();
    hw.displayHelloJni();// call the native function
    System.out.println("Back in Java, \"myNumber\" now " + hw.myNumber);
  }

  // Static code blocks are executed once, when class file is loaded
  static {
    System.loadLibrary("hello");
  }
}

第二步很简单;就像平常一样使用 javac HelloJni.java。对于像这样的简单程序,你可能不会遇到任何编译错误;如果有,纠正它们然后再次编译。

接下来,你需要创建一个 .h 文件。使用 javah 生成此文件:

javah jni.HelloJni           // produces HelloJni.h

生成的 .h 文件是一个粘合文件,实际上不是为人类消费而设计的,尤其不适合编辑。但通过检查生成的 .h 文件,你会看到 C 方法的名称由 Java 名称、包名称(如果有)以及类名称和方法名称组成:

JNIEXPORT void JNICALL Java_HelloJni_displayHelloWorld(JNIEnv *env,
    jobject this);

然后创建一个执行工作的 C 函数。你必须使用与 .h 文件中使用的相同的函数签名。

此函数可以随意执行任何操作。请注意,它传递了两个参数:JVM 环境变量和this对象的句柄。表 18-1 显示了 Java 类型和在 C 代码中使用的 C 类型(JNI 类型)之间的对应关系。

表 18-1. Java 和 JNI 类型

Java 类型 JNI Java 数组类型 JNI
byte jbyte byte[] jbyteArray
short jshort short[] jshortArray
int jint int[] jintArray
long jlong long[] jlongArray
float jfloat float[] jfloatArray
double jdouble double[] jdoubleArray
char jchar char[] jcharArray
boolean jboolean boolean[] jbooleanArray
void jvoid
Object jobject Object[] jobjectArray
Class jclass
String jstring
array jarray
Throwable jthrowable

示例 18-11 是一个完整的 C 本地实现。传递一个类型为Hel⁠lo​Jni.java的对象,它会增加对象中包含的整数myNumber

示例 18-11. main/src/main/java/jni/HelloJni.c
#include <jni.h>
#include "HelloJni.h"
#include <stdio.h>
/*
 * This is the Java Native implementation of displayHelloJni.
 */
JNIEXPORT void JNICALL Java_HelloJni_displayHelloJni(JNIEnv *env, jobject this) {
  jfieldID fldid;
  jint n, nn;

  (void)printf("Hello from a Native Method\n");

  if (this == NULL) {
    fprintf(stderr, "'this.' pointer is null!\n");
    return;
  }
  if ((fldid = (*env)->GetFieldID(env,
        (*env)->GetObjectClass(env, this), "myNumber", "I")) == NULL) {
    fprintf(stderr, "GetFieldID failed");
    return;
  }

  n = (*env)->GetIntField(env, this, fldid);/* retrieve myNumber */
  printf("\"myNumber\" value is %d\n", n);

  (*env)->SetIntField(env, this, fldid, ++n);/* increment it! */
  nn = (*env)->GetIntField(env, this, fldid);

  printf("\"myNumber\" value now %d\n", nn); /* make sure */
  return;
}

最后,将 C 代码编译成可加载对象。当然,具体细节取决于平台、编译器等。例如,在 Windows 上,您可以使用以下方法:

> set JAVA_HOME=C:\java              # or wherever
> set INCLUDE=%JAVA_HOME%\include;%JAVA_HOME%\include\Win32;%INCLUDE%
> set LIB=%JAVA_HOME%\lib;%LIB%
> cl HelloJni.c -Fehello.dll -MD -LD

并且在 Unix 上,您可以使用以下方法:

$ export JAVAHOME=/local/java   # or wherever
$ cc -I$JAVAHOME/include -I$JAVAHOME/include/solaris \
	-G HelloJni.c -o libhello.so

示例 18-12 是 Unix 的一个 makefile。

示例 18-12. main/src/main/java/jni/Makefile(Unix 版本)
# Configuration Section

CFLAGS_FOR_SO = -G # Solaris
CFLAGS_FOR_SO = -shared
CSRCS        = HelloJni.c
# JAVA_HOME should be been set in the environment
#INCLUDES    = -I$(JAVA_HOME)/include -I$(JAVAHOME)/include/solaris
#INCLUDES    = -I$(JAVA_HOME)/include -I$(JAVAHOME)/include/openbsd
INCLUDES    = -I$(JAVA_HOME)/include

all:        testhello testjavafromc

# This part of the Makefile is for C called from Java, in HelloJni
testhello:        hello.all
        @echo
        @echo "Here we test the Java code \"HelloJni\" that calls C code."
        @echo
        LD_LIBRARY_PATH=`pwd`:. java HelloJni

hello.all:        HelloJni.class libhello.so

HelloJni.class: HelloJni.java
        javac HelloJni.java

HelloJni.h:    HelloJni.class
        javah -jni HelloJni

HelloJni.o::    HelloJni.h

libhello.so:    $(CSRCS) HelloJni.h
    $(CC) $(INCLUDES) $(CFLAGS_FOR_SO) $(CSRCS) -o libhello.so

# This part of the Makefile is for Java called from C, in javafromc
testjavafromc:    javafromc.all hello.all
    @echo
    @echo "Now we test HelloJni using javafromc instead of java"
    @echo
    ./javafromc HelloJni
    @echo
    @echo "That was, in case you didn't notice, C->Java->C. And,"
    @echo "incidentally, a replacement for JDK program \"java\" itself!"
    @echo

javafromc.all:    javafromc

javafromc:    javafromc.o
    $(CC) -L$(LIBDIR) javafromc.o -ljava -o $@

javafromc.o:    javafromc.c
    $(CC) -c $(INCLUDES) javafromc.c

clean:
    rm -f core *.class *.o *.so HelloJni.h
clobber: clean
    rm -f javafromc

完成!只需在包含主程序的类文件上运行 Java 解释器。假设您已经设置了必要的系统相关设置(可能包括CLASSPATHLD_LIBRARY_PATH或其等效项),程序应该像下面这样运行:

C> java jni.HelloJni
Hello from a Native Method      // from C
"myNumber" value is 42          // from C
"myNumber" value now 43         // from C
Value of myNumber now 43        // from Java

祝贺!您已经调用了一个本地方法。但是,您放弃了可移植性;Java 类文件现在要求您为每个操作系统和硬件平台构建一个可加载对象。乘以{Windows,Mac OS X,Sun Solaris,HP/UX,Linux,OpenBSD,NetBSD,FreeBSD}乘以{Intel-32,Intel-64/AMD64,Arm,Arm-64,以及可能的 SPARC64,PowerPC 和 HP-PA},您开始看到可移植性问题。

注意,您的本地代码问题可能会导致运行时进程崩溃,从而使 Java 虚拟机无法保护自己。 JVM 无法防止由于编写不良的 C/C++代码而导致的问题。内存必须由程序员管理;系统运行时分配器获取的内存没有自动垃圾回收机制。您直接与操作系统甚至硬件打交道,所以,请小心。非常小心。

另请参阅

如果您需要更多关于 Java 本地方法的信息,您可能会对 Rob Gordon 的Essential JNI: Java Native Interface(Prentice Hall 出版)中的全面处理感兴趣。

18.7 从本地代码调用 Java

问题

您需要采用另一种方式,从 C/C++代码调用 Java。

解决方案

再次使用 JNI。

讨论

JNI(Java Native Interface)提供了一个接口,用于从 C 调用 Java,包括以下调用:

  1. 创建一个 JVM。

  2. 载入一个类。

  3. 查找并调用该类中的一个方法(例如 main)。

JNI 允许您将 Java 添加到遗留代码中。这对多种用途非常有用,并允许您将 Java 代码视为扩展语言。

示例 18-13 中的代码从命令行获取一个类名,启动 JVM,并调用该类中的 main() 方法。

示例 18-13. main/src/main/java/jni/javafromc.c(从 C 调用 Java)
/*
 * This is a C program that calls Java code.
 * This could be used as a model for building Java into an
 * existing application as an extention language, for example.
 */

#include <stdio.h>
#include <jni.h>

int
main(int argc, char *argv[]) {
    int i;
    JavaVM *jvm;        /* The Java VM we will use */
    JNIEnv *myEnv;        /* pointer to native environment */
    JDK1_1InitArgs jvmArgs; /* JNI initialization arguments */
    jclass myClass, stringClass;    /* pointer to the class type */
    jmethodID myMethod;    /* pointer to the main() method */
    jarray args;        /* becomes an array of Strings */
    jthrowable tossed;    /* Exception object, if we get one. */

    JNI_GetDefaultJavaVMInitArgs(&jvmArgs);    /* set up the argument pointer */
    /* Could change values now, like: jvmArgs.classpath = ...; */

    /* initialize the JVM! */
    if (JNI_CreateJavaVM(&jvm, &myEnv, &jvmArgs) < 0) {
        fprintf(stderr, "CreateJVM failed\n");
        exit(1);
    }

    /* find the class named in argv[1] */
    if ((myClass = (*myEnv)->FindClass(myEnv, argv[1])) == NULL) {
        fprintf(stderr, "FindClass %s failed\n", argv[1]);
        exit(1);
    }

    /* find the static void main(String[]) method of that class */
    myMethod = (*myEnv)->GetStaticMethodID(
        myEnv, myClass, "main", "([Ljava/lang/String;)V");
    /* myMethod = (*myEnv)->GetMethodID(myEnv, myClass, "test", "(I)I"); */
    if (myMethod == NULL) {
        fprintf(stderr, "GetStaticMethodID failed\n");
        exit(1);
    }

    /* Since we're calling main, must pass along the command line arguments,
 * in the form of Java String array
 */
    if ((stringClass = (*myEnv)->FindClass(myEnv, "java/lang/String")) == NULL){
        fprintf(stderr, "get of String class failed!!\n");
        exit(1);
    }

    /* make an array of Strings, subtracting 1 for progname & 1 for the
 * java class name */
    if ((args = (*myEnv)->NewObjectArray(myEnv, argc-2, stringClass, NULL))==NULL) {
        fprintf(stderr, "Create array failed!\n");
        exit(1);
    }

    /* fill the array */
    for (i=2; i<argc; i++)
        (*myEnv)->SetObjectArrayElement(myEnv,
            args, i-2, (*myEnv)->NewStringUTF(myEnv, argv[i]));

    /* finally, call the method. */
    (*myEnv)->CallStaticVoidMethodA(myEnv, myClass, myMethod, &args);

    /* And check for exceptions */
    if ((tossed = (*myEnv)->ExceptionOccurred(myEnv)) != NULL) {
        fprintf(stderr, "%s: Exception detected:\n", argv[0]);
        (*myEnv)->ExceptionDescribe(myEnv);    /* writes on stderr */
        (*myEnv)->ExceptionClear(myEnv);    /* OK, we're done with it. */
    }

    (*jvm)->DestroyJavaVM(jvm);    /* no error checking as we're done anyhow */
    return 0;
}

====

¹ kwrite 是 Unix 特有的;它是 K Desktop Environment (KDE) 的一部分。

后记

编写这本书——并且不断更新它——是一次令人感到谦卑的经历。花费的时间比我预测的要长得多,也比我想承认的要长。当然,它还没有完全完成。尽管我和技术审阅人员、编辑和许多其他才华横溢的人士都尽了最大努力,但这样一本厚重的书必然会包含错误、遗漏和不太清晰的段落。如果你发现其中任何问题,请告诉我们;你可以通过书的目录页面查看并提交勘误。后续版本将会包含像你一样的读者发送的更改!

有人说过,直到你教会别人,你才真正了解某件事。我发现这对讲课是正确的,同样适用于写作。

我告诉我的学生,当 Java 非常年轻的时候,一个人努力学习几乎可以了解它的一切。经过几次发布后,这种说法不再成立。今天,没有一个正常的人会认真声称“对 Java 了解所有”—如果有人这样做,你的虚假检测器应该会全力响起。而你需要了解的内容也在不断增加。你如何跟上?Java 书籍?Java 杂志?Java 课程?会议?没有单一的答案;所有这些对一些人都是有用的。Oracle 和其他公司有一些你应该了解的程序:

  • 多年来,JavaOne 一直是由 Sun Microsystems 和短暂由 Oracle 主办的主要 Java 会议。最近,Oracle 已将其整合到Code One,这是年度 Oracle 会议。

  • Marcus Biel 有一个相当完整的全球 Java 会议列表

  • Oracle Java 技术网络,一个免费的基于 Web 的服务,提供最新的 API、新闻和观点。

  • 在 Java 的整个生命周期中,出版业发生了很多变化。以前曾经有几本关于 Java 的杂志印刷版出版,其中一些文章也会出现在网上。今天据我所知,已经没有专门关于 Java 的印刷杂志了。Oracle 目前(2020 年)每月发行仅在线版的Java Magazine,包含关于 Java 许多方面的技术文章(包括一些是我写的);请访问杂志的网站查看最新期和过往期刊。

  • Java 社区进程,Java 标准化和增强的主页。

  • OpenJDK 社区维护和构建“官方”JDK 的开源版本。

  • O’Reilly 的书籍会议是目前最好的选择之一!

  • 我会不定期更新我自己的 Java 资源列表,你可以在我的 Java 网站上查看;点击 Java 资源链接。

  • 最有趣的高级话题讨论出现在 Heinz Kabutz 的 Java 专家通讯 中。

学习 Java API 永无止境。而且还有更多的书籍需要编写……以及阅读。

附录 A. Java 的过去与现在

引言:Java 总是在不断变化

Java 一直是开发者和作者的移动目标。在我的商业培训项目中,我会遇到一些开发者,他们甚至还不知道一些早期的 Java 发行版新增的功能,更不用说现在了。本附录将审视每个主要的 Java 发行版。请参阅乔恩·拜厄斯的 Sun Microsystems 文章《Java 技术:早期岁月》以了解 Java 的早期历史。你也可以在帕德博恩大学网站找到一份副本。¹

Java 8 之前的发布详情被视为古代历史,并已移至我的网站,https://darwinsys.com/java/ancientHistory.html

Java 8 中的新功能

Java 8 语言变更

Java 8 语言中最重要的新特性是 lambda 表达式。经过十年的讨论如何实现闭包或 lambda 表达式,它们终于在 Java 8 中到来了。这个话题如此广泛,以至于在本版本中专门有一章来讨论;请参阅 第九章。

现在可以在结构化类型上放置注解。

Java 8 API 变更

Java 8 引入了来自 JSR-310 的新日期/时间 API。这提供了一组更一致和合理的类和例程来处理时间。第六章 已完全重写以使用新 API,并以一个示例展示了旧 API 和新 API 之间的各种转换。

Java 8 引入了闭包、Streams 和并行集合等函数式编程技术,我们将在 第九章 中讨论它们。为了支持 Streams,接口中有了新的方法,如 ListMapSet,这些接口自 Java 1.1 以来基本没有变化。幸运的是,Java 8 语言支持在接口中添加了一种 default 方法类型,因此你的自定义接口实现不需要更改(只要确保将 IDE 设置为最新的编译器级别)。

作为默认方法的一个示例,Iterable 获得了一个名为 forEach() 的新默认方法,使你可以编写这样的代码:

myList.forEach(e -> /* do something with e here... */);

这在 “Iterable.forEach method (Java 8)” 中进一步讨论。

一种名为 Nashorn 的新 JavaScript 实现现在通过 javax.script 可用(参见 Recipe 18.3),也可以从命令行运行。

Javadoc(参见 Recipe 15.2)已扩展到 javax.tools API。

注解可以重复使用,无需手动编写包装注解,例如 javax.persistence.NamedQueries(复数形式),它只是 javax.persistence.NamedQuery(单数形式)注解的一个容器。

最后,Java 提供了对 Base 64 编码/解码的支持,形式为 java.util.Base64,其中包括用于编码和解码的两个嵌套类。

还有许多其他小的变更,例如那些由 OpenJDK 讨论的。

Java 9 中的新功能

Java 9 以引入 Java 平台模块系统(JPMS)而闻名。

由于 JDK 本身已经模块化(JPMS 的最初意图!),新的 jlink 工具允许您仅为模块化应用程序构建仅包含所需部分的最小 JDK。

另一个新工具是 JShell,一个用于 Java 的 REPL(读取-求值-打印-循环)表达式求值器。也被称为交互式 Java,JShell 对于原型设计、尝试新想法等非常有用。JShell 在 Recipe 1.4 中有介绍。

这个版本还标志着每六个月发布一个新的主要版本(Java 10、Java 11 等)的开始。同时,Java 8 和 Java 11 被宣布为 LTS(长期支持)版本。

Java 9 语言变更

新的 module-info 文件引入了几个伪关键字,这些词在 module-info 文件中具有保留意义,但仍可用作 Java 类中的用户定义名称。其中包括 module、requires、exports、provides、with 等。这也影响了在模块内部使用时的可见性修饰符的含义。

接口(在 Java 8 中添加了默认方法)现在还允许私有方法,供默认方法使用。

Java 9 API 变更

流 API 的改进,Stream 接口新增了几种新方法。

改进的集合 API,包括 of() 工厂方法,可以快速创建包含多个值的 ListSet

Java 10 新特性(2018 年 3 月)

Java 10 以 var 关键字及首次实现六个月发布周期而闻名。

Java 10 引入了 GraalVM,一个基于 Java 编写的即时编译器(类似于 HotSpot)。

在 Java 10 中,OpenJDK 版本的 cacerts 文件已完全填充,使得通过 https 连接的工作更加顺利。

本地代码头文件工具 javah 被移除,其功能由 javac 自身提供了等效或更好的功能。

Java 10 语言变更

var 关键字仅适用于局部变量,允许您不必担心变量的实际类型。当然,编译器必须能够推断出变量的类型。让我们在 jshell 中探索一些选项:

jshell> var x = 10;
x ==> 10

jshell> var y = 123.4d;
y ==> 123.4

jshell> var z = java.time.LocalDateTime.now();
z ==> 2019-08-31T20:47:36.440491

jshell> var map = new HashMap<String,Integer>();
map ==> {}

jshell> map.put("Meh", 123);
$4 ==> null

jshell> var huh;
|  Error:
|  cannot infer type for local variable huh
|    (cannot use 'var' on variable without initializer)
|  var huh;
|  ^------^

jshell>

有些出人意料的是,var 实际上不是语言关键字,因此此词仍可用作用户定义名称:

jshell> var var = 123;
var ==> 123

jshell> var
var ==> 123

查看 https://developer.oracle.com/java/jdk-10-local-variable-type-inference.html 了解关于 var 的解释和更多细节。

Java 10 API 变更

ListSet 添加了新的 copyOf() 方法来创建真正的不可修改副本;以前的 List.unmodifiableList() 创建了一个不可修改视图,如果底层 List 更改,它将会发生变化。

参见

删除或弃用了相当多的旧功能;请参阅这个DZone 上的列表

Simon Ritter 撰写了一篇题为“Java 10 不慎落入陷阱”的文章。

Java 11 的新特性(2018 年 9 月)

Java 11 引入了我所称之为“从源码单文件运行”(JEP 330);您现在可以输入以下内容:

java HelloWorld.java

并且 java 命令将编译并运行指定的程序。这使得处理单个文件变得更加容易,这也是它的主要应用。如果您有两个或更多文件,则必须在 CLASSPATH 上编译第二个到 n 个文件;您在命令行中指定的源文件必须是带有 main() 方法的文件,并且不能在 CLASSPATH 上编译。因此,对于简单的事物很有用,但对于复杂的应用则不适用。

还请参阅DZone 上的此列表

Java 11 API 变更

有关 Java 11 变更的更完整列表,请参阅此 DZone 列表

Java 12 的新变更(2019 年 3 月)

Java 12 引入了预览变更的概念,向 JDK 中添加的功能尚未成为官方规范的一部分。这基本上是其他人可能称之为测试模式;如果足够多的用户表示对预览模式功能有严重问题,JDK 团队可以在将其声明为 JDK 规范的一部分之前修复或淘汰它。

Java 12 语言变更

  • switch 语句可以返回一个值(预览版)

Java 12 API 变更

一些更显著的变更:

  • 用于 Stream 的 Tee Collector(将输入复制到多个输出 Stream)。

  • CompactNumberFormat 替换了我的 ScaledNumberFormat(例如,将数字 2,048 打印为 2K)。

  • String.indent(n) 返回在 String 前面添加了 n 个空格的副本。

  • 垃圾收集改进(JEP 189:Shenandoah:低暂停时间 GC);G1 GC 的暂停时间改进。

还有许多其他次要变更,请参阅https://www.azul.com/39-new-features-and-apis-in-jdk-12https://openjdk.java.net/projects/jdk/12

Java 13 的新特性(2019 年 9 月)

至撰写本文时,Java 13 是最新的正式版本。它包括以下功能:

  • 改进的垃圾收集(再次)

  • 改进的应用程序类数据共享(AppCDS)允许编写一个包含应用程序中所有使用的类的归档文件。

  • 文本块来替代和简化多行 String 文字(预览版)

  • 改进的 switch 语句,可以返回一个值

  • 重写了SocketServerSocket的实现(不改变 API)。

参见这篇 JavaWorld 文章

展望未来

2020 年将会有一个 Java 14,大约在本书印刷时期。

这些是正在开发中的一些功能:

  • 记录类型(在预览中;参见 Recipe 7.18)。

  • 封闭类型,允许类设计者通过列出所有允许的子类来控制子类化。目前的语法看起来是这样的:

    public abstract sealed class Person permits Customer, SalesRep {
        ...
    }
    class Customer extends Person {
        ...
    }
    
  • 文本块,即用三重双引号括起来的多行文本字符串:

    String long = """
    This is a long
    text String."""
    
  • 一个新的打包工具,jpackage,将在主要支持的操作系统上生成一个完整的自安装应用程序。

Java 14 还有几个其他有趣的 JEPs。完整列表可在OpenJDK找到。从该页面链接的 JEP 对于那些对每个新功能的原因(以及所需工作量)感兴趣的人来说是有趣的阅读。

2020 年还将有一个 Java 15,但它在本书印刷时刚进入早期访问阶段,因此我们在这个版本中没有覆盖它。“Java 的未来总是在变动中”,如 Yoda 所言。

¹ Sun Microsystems,“Java 技术:早期岁月”的文章可以在https://web.archive.org/web/20090311011509/http://java.sun.com/features/1998/05/birthday.html和 Paderborn 大学网站[*http://gcc.upb.de/www/WI/WI2/wi2_lit.nsf/7544f3043ee53927c12573e70058bbb6/abf8d70f07c12eb3c1256de900638899/\(FILE/Java%20Technology%20-%20An%20early%20history.pdf*](http://gcc.upb.de/www/WI/WI2/wi2_lit.nsf/7544f3043ee53927c12573e70058bbb6/abf8d70f07c12eb3c1256de900638899/\)FILE/Java%20Technology%20-%20An%20early%20history.pdf)找到。

posted @ 2024-06-15 12:22  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报