Java17 零基础入门手册(六)
十、让您的应用具有交互性
到目前为止,我们的 Java 程序数据的输入是通过在代码内部初始化的数组或变量或者通过程序参数提供的。然而,在现实生活中,大多数应用都需要与用户进行交互。可以通过输入用户名和密码向用户提供访问,并且用户有时需要键入细节来确认他/她的身份或者指示应用做什么。Java 支持多种方法来读取用户输入。在这一章中,我们将介绍几种构建交互式 Java 应用的方法。交互式 Java 应用从控制台、Java 构建的界面以及桌面或 web 获取输入。
JShell 是一个命令行界面,开发者可以在这里输入变量声明和单行语句,当按下时就会执行这些声明。像bash这样的命令行界面外壳和像来自 Windows 的命令提示符这样的终端可以用来以连续文本行的形式向程序发出命令。JShell 在本书的开始部分已经介绍过了,原因很简单,因为它是 Java 9 的一个新事物。以下部分将介绍如何使用命令行界面读取用户提供的数据和指令。之后的章节将集中在构建带有桌面/web 界面的 Java 应用上。
从命令行读取数据
本节专门介绍如何从命令行读取用户输入,无论是 IntelliJ IDEA 控制台还是从特定于操作系统的任何终端的可执行 jar 中运行程序。在 JDK 中,有两个类可以用来从命令行读取用户数据:java.util.Scanner和java.io.Console,本节将详细介绍这两个类。事不宜迟,我们开始吧。
使用System.in读取用户数据
在章节 9 介绍在System.out下的控制台方法中记录打印数据之前,它们在本书的代码示例中经常使用。还有一个名为System.in的对应实用程序对象,用于从控制台读取数据:程序用户引入的用于控制应用流的数据。您可能已经注意到,到目前为止,所有 Java 程序在执行时都会被启动,会处理数据,会执行声明的语句,然后它们会终止,优雅地退出,或者在出错时出现异常。将终止决策传递给用户的最简单、最常见的方式是通过调用System.in.read()来结束 main 方法。该方法从输入流中读取数据的下一个字节,程序暂停,直到用户输入一个值;当值返回时,我们甚至可以保存并打印它。清单 10-1 显示了使用System.in.read读取用户输入的代码。
package com.apress.bgn.ten;
import java.io.IOException;
public class ReadingFormStdinDemo {
public static void main(String... args) throws IOException {
System.out.print("Press any key to terminate:");
byte[] b = new byte[3];
int read = System.in.read(b);
for (int i = 0; i < b.length; ++i) {
System.out.println(b[i]);
}
System.out.println("Key pressed: " + read);
}
}
Listing 10-1Reading a Value Provided By the User in the Console
用户输入保存在byte[] b数组中;它的大小是任意的。你可以输入任何你想要的东西。只有前三个字节会保存在数组中。然而,这种阅读信息的方式并没有真正的用处,不是吗?我的意思是,看看下面的代码片段,它描述了前面执行的代码和插入的随机文本。
Press any key to terminate: ini mini miny moo. # inserted text
32
105
110
Key pressed: 3
让我们看看如何从用户那里读取全文:输入 class java.util.Scanner。
使用java.util.Scanner
System.in变量的类型是java.io.InputStream,这是一个 JDK 特殊类型,由所有表示输入字节流的类扩展。你会在第章 11 中了解到更多关于InputStream这个职业的信息。这意味着System.in可以包装在任何java.io.Reader扩展中(阅读章节 11 了解更多信息),因此字节可以作为可读数据读取。真正重要的是包java.util中一个名为Scanner的类。这种类型的实例可以通过调用其构造函数并提供System.in作为参数来创建。Scanner类提供了许多next*()方法,可以用来从控制台读取几乎任何类型。在图 10-1 中可以看到next*()方法列表。
图 10-1
读取各种类型数据的扫描仪方法
使用Scanner从控制台读取数据的优点是,读取的值在可能的情况下会自动转换为适当的类型;如果不是,则抛出一个java.util.InputMismatchException。
下面这段代码旨在让您可以通过插入文本然后插入值来选择要读取的值的类型。在清单 10-2 中,Scanner 实例的适当方法被调用来读取值。
package com.apress.bgn.ten;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class ReadingFromStdinUsingScannerDemo {
public static final String EXIT = "exit";
public static final String HELP = "help";
public static final String BYTE = "byte";
public static final String SHORT = "short";
public static final String INT = "int";
public static final String BOOLEAN = "bool";
public static final String DOUBLE = "double";
public static final String LINE = "line";
public static final String BIGINT = "bigint";
public static final String TEXT = "text";
public static final String LONGS = "longs";
public static void main(String... args) {
Scanner sc = new Scanner(System.in);
String help = getHelpString();
System.out.println(help);
String input;
do {
System.out.print("Enter option: ");
input = sc.nextLine();
switch (input) {
case HELP:
System.out.println(help);
break;
case EXIT:
System.out.println("Hope you had fun. Buh-bye!");
break;
case BYTE:
byte b = sc.nextByte();
System.out.println("Nice byte there: " + b);
sc.nextLine();
break;
case SHORT:
short s = sc.nextShort();
System.out.println("Nice short there: " + s);
sc.nextLine();
break;
case INT:
int i = sc.nextInt();
System.out.println("Nice int there: " + i);
sc.nextLine();
break;
case BOOLEAN:
boolean bool = sc.nextBoolean();
System.out.println("Nice boolean there: " + bool);
sc.nextLine();
break;
case DOUBLE:
double d = sc.nextDouble();
System.out.println("Nice double there: " + d);
sc.nextLine();
break;
case LINE:
String line = sc.nextLine();
System.out.println("Nice line of text there: " + line);
break;
case BIGINT:
BigInteger bi = sc.nextBigInteger();
System.out.println("Nice big integer there: " + bi);
sc.nextLine();
break;
case TEXT:
String text = sc.next();
System.out.println("Nice text there: " + text);
sc.nextLine();
break;
default:
System.out.println("No idea what you want bruh!");
}
} while (!input.equalsIgnoreCase(EXIT));
}
private static String getHelpString() {
return new StringBuilder("This application helps you test various usage of Scanner. Enter type to be read next:")
.append("\n\t help > displays this help")
.append("\n\t exit > leave the application")
.append("\n\t byte > read a byte")
.append("\n\t short > read a short")
.append("\n\t int > read an int")
.append("\n\t bool > read a boolean")
.append("\n\t double > read a double")
.append("\n\t line > read a line of text")
.append("\n\t bigint > read a BigInteger")
.append("\n\t text > read a text value").toString();
}
}
Listing 10-2Reading a Value Provided By the User in the Console Using java.util.Scanner
您可能已经注意到,在前面的代码示例中,大多数 scanner 方法都是用一个nextLine()一起调用的。这是因为您提供的每个输入都是由实际的令牌和一个新的行字符组成的(按下< Enter >结束您的输入),在您可以输入下一个值之前,您还需要从流中取出那个字符。
清单 10-3 描述了清单 10-2 中的代码被用来读取一些用户值。
This application helps you test various usage of Scanner. Enter type to be read next:
help > displays this help
exit > leave the application
byte > read a byte
short > read a short
int > read an int
bool > read a boolean
double > read a double
line > read a line of text
bigint > read a BigInteger
text > read a text value
Enter option: byte
12
Nice byte there: 12
Enter option: bool
true
Nice boolean there: true
Enter option: line
some of us are hardly ever here
Nice line of text there: some of us are hardly ever here
Enter option: text
john
Nice text there: john
Enter option: text
the rest of us are made to disappear...
Nice text there: the
Enter option: double
4.2
Nice double there: 4.2
Enter option: int
AAAA
Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:939)
at java.base/java.util.Scanner.next(Scanner.java:1594)
at java.base/java.util.Scanner.nextInt(Scanner.java:2258)
at java.base/java.util.Scanner.nextInt(Scanner.java:2212)
at chapter.ten.scanner/com.apress.bgn.ten.ReadingFromStdinUsingScannerDemo.main(ReadingFromStdinUsingScannerDemo.java:80)
Listing 10-3Running the ReadingFromStdinUsingScannerDemo Class
前面清单中突出显示的输出代表了next()方法的测试用例。这个方法应该用于读取单个String令牌。下一个标记被转换成一个String实例,很明显,当遇到空格时,标记结束。这就是为什么在前面的例子中,唯一读取的文本最终是的。在最后一种情况下,期望的选项是一个整数值,但是输入了 AAAA ,这就是抛出异常的原因。
当您需要从控制台重复读取相同类型的值时,您可以查看您想要读取的值,并在读取之前检查它,以避免抛出InputMismatchException。对于这个特定的场景,每个next*()方法都有一个名为hasNext*()的配对方法。为了展示如何使用这些方法的示例,让我们在前面的代码中添加一个选项,以便能够读取长值列表,如清单 10-4 所示。
...
public static final String LONGS = "longs";
...
String input;
do {
System.out.print("Enter option: ");
input = sc.nextLine();
switch (input) {
case LONGS:
List<Long> longList = new ArrayList<>();
while (sc.hasNextLong()) {
longList.add(sc.nextLong());
}
System.out.println("Nice long list there: " + longList);
// else all done
sc.nextLine();
sc.nextLine();
break;
default:
System.out.println("No idea what you want bruh!");
}
} while (!input.equalsIgnoreCase(EXIT));
...
Listing 10-4Using java.util.Scanner to Read a List of Long Values
虽然看起来很怪异,但是我们需要调用两次nextLine()方法:一次是针对无法转换为long的字符,所以while循环结束,一次是针对行尾字符,所以下一次读取的是下面读取值的类型。
在Scanner类中还有一些其他的方法可以用来过滤输入和只读所需的令牌,但是本节中列出的方法是您将会使用最多的。
使用java.io.Console
java.io.Console类是在比Scanner,晚一个版本的 Java 版本 1.6 中引入的,它提供了访问与当前 Java 虚拟机关联的基于字符的控制台设备(如果有的话)的方法。
因此,类java.io.Console的方法也可以用于写入控制台,而不仅仅是读取用户输入。如果从后台进程或 Java 编辑器启动 JVM,控制台将不可用,因为编辑器会将标准输入和输出流重定向到它自己的窗口。这就是为什么如果我们要使用Console编写代码,我们只能通过从终端运行类或 jar,通过调用java ReadingUsingConsoleDemo.class或java -jar using-console-1.0-SNAPSHOT.jar来测试它。JVM 的控制台,如果可用的话,在代码中由类Console的单个实例来表示,它可以通过调用System.console()来获得。
在图 10-2 中,您可以看到可以在控制台实例上调用的方法。
图 10-2
读取各种类型数据的扫描仪方法
显然,read*(..)方法用于从控制台读取用户输入,printf(..)和format(..)用于在控制台打印文本。这里的特例是两个readPassword(..)方法,它们允许从控制台读取文本,但在写的时候不显示。这意味着可以在没有任何实际用户界面的情况下编写支持身份验证的 Java 应用。清单 10-5 描述了一个示例代码来查看所有的操作。
package com.apress.bgn.ten;
import java.io.Console;
import java.util.Calendar;
import java.util.GregorianCalendar;
public class ReadingUsingConsoleDemo {
public static void main(String... args) {
Console console = System.console();
if (console == null) {
System.err.println("No console found.");
return;
} else {
console.writer().print("Hello there! (reply to salute)\n");
console.flush();
String hello = console.readLine();
console.printf("You replied with: '" + hello + "'\n");
Calendar calendar = new GregorianCalendar();
console.format("Today is : %1$tm %1$te,%1$tY\n", calendar);
char[] passwordChar = console.readPassword("Please provide password: ");
String password = new String(passwordChar);
console.printf("Your password starts with '" + password.charAt(0) + "' and ends with '" + password.charAt(password.length()-1) + "'\n");
}
}
}
Listing 10-5Using java.io.Console to Read and Write Values
在前面的代码示例中,有意使用了各种使用控制台读写数据的方法,以向您展示应该如何使用它们。
console.writer()返回一个java.io.PrintWriter的实例,它可以用来将消息打印到控制台。问题是直到console.flush()被调用,消息才被打印出来。这意味着更多的消息可以被java.io.PrintWriter实例排队,并且只有当flush()被调用或者当它的内部缓冲区满了的时候才被打印。调用console.format(..)来打印格式化的消息,在本例中,使用一个Calendar实例来提取当前日期,并根据下面的模板打印出来:dd mm,yyyy由这个参数%1$tm %1$te,%1$tY定义。使用格式化程序的Console方法接受的模板在类java.util.Formatter中定义。
复杂的部分:在 IntelliJ 中运行这段代码是不可能的,所以我们必须要么执行类,要么执行 jar。
为了避免在运行代码时创建新的操作系统控制台窗口,大多数 ide,比如 IntelliJ IDEA,都使用无窗口 Java。因为没有窗口,所以没有供用户访问和插入数据的控制台。所以使用 java
. io . Console 的应用必须在命令行中执行。
最简单的方法是配置 Maven maven-jar-plugin来创建一个可执行的 jar,要执行的主类是ReadingUsingConsoleDemo。Maven 生产的罐子可以在这里找到:/chapter10/using-console/target/using-console-2.0-SNAPSHOT.jar。如果你想的话,只需在 IntelliJ IDEA 中打开一个终端,点击终端按钮,进入target目录。一旦到了那里,执行java -jar using-console-2.0-SNAPSHOT.jar并享受其中的乐趣。在清单 10-6 中,你可以看到我用来测试程序的条目。
> cd chapter10/using-console/target
> java -jar using-console-2.0-SNAPSHOT.jar
Hello there! (reply to salute)
Salut!
You replied with: 'Salut!'
Today is: 06 21,2021
Please provide password:
Your password starts with 'g' and ends with 'a'
Listing 10-6Running the Class ReadingUsingConsoleDemo
这是关于使用控制台的所有值得介绍的内容,因为一旦您在一个真正的生产就绪项目中工作,您可能永远都不需要它。
使用 Swing 构建应用
Swing 是一个用于 Java 的 GUI 部件工具包。从版本 1.2 开始,它是 JDK 的一部分,旨在为构建具有各种按钮、进度条、可选列表等复杂界面的用户应用提供更加美观和实用的组件。Swing 是基于一个叫做 AWT(简称抽象窗口工具包 ) 的东西的早期版本,它是最初的 Java 用户界面小部件工具包。AWT 非常简单,有一组图形界面组件,可以在任何平台上使用,这意味着 AWT 是可移植的,但这并不意味着在一个平台上编写的 AWT 代码实际上可以在另一个平台上工作,因为平台特定的限制。AWT 组件依赖于本机等效组件,这就是它们被命名为重量级组件的原因。在图 10-3 中,你可以看到一个简单的 Java AWT 应用。
图 10-3
简单的 Java AWT 应用
这是一个简单的窗口,包含一个列表、一个文本区域和一个按钮。主题,也称为应用的外观和感觉,与构建它的操作系统是同一个主题——在本章的例子中是 macOS。由于前面提到的原因,它不能被改变:AWT 接入 OS 本地图形界面。如果你在 Windows 机器上运行相同的代码,窗口看起来会不同,因为它将使用 Windows 主题。
Swing 组件是用 Java 构建的,遵循 AWT 模型,但是提供了一个可插拔的外观。Swing 完全用 Java 实现,包含 AWT 的所有特性,但是它们不再依赖于原生 GUI 这就是为什么它们被称为轻型组件。Swing 提供了 AWT 所做的一切,并且用更高级的组件扩展了组件集,比如树形视图、列表框和选项卡式窗格。此外,外观和感觉以及主题是可插拔的,可以很容易地改变。这显然意味着比 AWT 应用更好的可移植性:用非特定于平台的组件编写更复杂的应用设计的可能性,并且因为 Swing 是 AWT 的替代方案,所以在它上面做了更多的开发。
当 web 应用飞速发展时,它们的用户界面非常简单,因为浏览器的功能非常有限。引入 AWT 是为了构建名为 applets 的 Java web 应用。Java 小程序是从浏览器启动的小应用,然后在安装在用户操作系统上的 JVM 中,在独立于浏览器本身的进程中执行。这就是为什么小应用可以在网页框架、新的应用窗口或为测试小应用而设计的独立工具中运行。Java 小程序使用操作系统的 GUI,这使得它们比当时 HTML 笨重的初始外观更漂亮。它们现在已被弃用,并计划在 Java 11 中删除。
至于用 Swing 或 AWT 编写的 Java 桌面应用,它们已经很少使用了,你可能会在学校里学着构建一个,但在其他方面已经过时了。然而,某些机构和公司使用的遗留应用已经在他们的业务中运行了很长时间,并且是用 Swing 构建的。我见过餐馆使用 Swing 应用来管理桌子和订单,我认为大多数超市也使用 Swing 应用来管理购物项目。这就是本书中存在这一部分的原因,因为你可能最终要维护这样的应用,了解基础知识是有好处的,因为 Swing 仍然是 JDK 的一部分。所有 Swing 组件(AWT 也是)都是java.desktop模块的一部分。所以如果你想使用 Swing 组件,你必须声明对这个模块的依赖。在清单 10-7 中,显示了一个配置片段。你可以看到我们项目中使用 Swing 的模块通过在其 module-info.java 中使用requires指令声明了它对java.desktop模块的依赖。
module chapter.ten.swing {
requires java.desktop;
}
Listing 10-7Module Configuration for the using-swing Project
图 10-3 中描述的应用是使用 AWT 构建的。本节将介绍在 Swing 中构建类似的东西,甚至向它添加更多组件。任何 Swing 应用的主类都被命名为JFrame,该类型的实例用于创建带有边框和标题的窗口。清单 10-8 中的代码就是这么做的。
package com.apress.bgn.ten;
import javax.swing.*;
import java.awt.*;
public class BasicSwingDemo extends JFrame {
public static void main(String... args) {
BasicSwingDemo swingDemo = new BasicSwingDemo();
swingDemo.setTitle("Swing Demo Window");
swingDemo.setSize(new Dimension(500,500));
swingDemo.setVisible(true);
}
}
Listing 10-8Swing Application with a Simple Title
在前面的代码中,创建了一个javax.swing.JFrame的实例,为它设置了一个标题,我们还设置了一个大小,这样当窗口创建时,我们就可以看到一些东西。要真正显示窗口,必须在 JFrame 实例上调用setVisible(true)。运行前面的代码时,显示一个如图 10-4 所示的窗口。
图 10-4
简单的 Java Swing 应用
默认情况下,窗口位于主监视器的左上角,但是可以通过使用一些 Swing 组件来计算相对于屏幕大小的位置来进行更改。确定一个相对于屏幕大小的摆动窗口的大小和位置,仅仅受限于你愿意投入的数学量。
此时,如果我们关闭显示的窗口,应用将继续运行。默认情况下,关闭窗口只是通过调用setVisible(false)使其不可见。如果我们想改变默认行为退出应用,我们必须改变关闭时的默认操作。这可以通过在创建 JFrame 实例后添加以下代码行来轻松完成。
swingDemo.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
JFrame.EXIT_ON_CLOSE常量是定义窗口关闭时应用行为的一组常量的一部分。这个用来声明当窗口关闭时应用应该退出。其他相关选项包括:
-
不执行任何操作,包括关闭窗口。
-
HIDE_ON_CLOSE是导致调用setVisible(false)的默认选项。 -
当一个应用有多个窗口时,使用
DISPOSE_ON_CLOSE;此选项用于在最后一个可显示窗口关闭时退出应用。
大多数 Swing 应用都是通过扩展JFrame类来获得对其组件的更多控制而编写的,因此前面的代码也可以如清单 10-9 所示来编写:
package com.apress.bgn.ten;
import javax.swing.*;
import java.awt.*;
public class ExitingSwingDemo extends JFrame {
public static void main(String... args) {
ExitingSwingDemo swingDemo = new ExitingSwingDemo();
swingDemo.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
swingDemo.setTitle("Swing Demo Window");
swingDemo.setSize(new Dimension(500,500));
swingDemo.setVisible(true);
}
}
Listing 10-9Swing Application That Exits When Closed
现在我们有了一个窗口,让我们开始添加组件,因为如果我们没有更多的组件来让我们注意到变化,那么改变外观是没有意义的。每个 Swing 应用至少有一个JFrame是根,是所有其他窗口的父窗口,因为窗口也可以通过使用JDialog类来创建。JDialog是创建对话窗口的主类,这是一种特殊类型的窗口,主要包含消息和选择选项的按钮。开发者可以使用这个类来创建自定义的对话框窗口,或者使用JOptionPane类方法来创建各种对话框窗口。
回到将组件添加到JFrame实例:通过将组件添加到容器中,将组件添加到JFrame中。对JFrame容器的引用可以通过调用getContentPane()来检索。默认的内容窗格是一个简单的中间容器,继承自JComponent,它扩展了java.awt.Container (Swing 是 AWT 的扩展,它的大部分组件都是 AWT 扩展)。对于JFrame,默认的内容窗格实际上是JPanel的一个实例。这个类有一个类型为java.awt.LayoutManager的字段,它定义了其他组件如何在JPanel中排列。一个JFrame实例的默认内容窗格使用一个java.awt.BorderLayout作为它的布局管理器,将一个窗格分成五个区域:东、西、北、南和中心。每个区域都可以被一个常量引用,该常量具有在BorderLayout中定义的匹配名称,所以如果我们想在我们的应用中添加一个退出按钮,我们可以通过编写清单 10-10 中描述的代码将其添加到南部区域。
package com.apress.bgn.ten;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class LayeredSwingDemo extends JFrame {
private JPanel mainPanel;
private JButton exitButton;
public LayeredSwingDemo(String title) {
super(title);
mainPanel = (JPanel) this.getContentPane();
exitButton = new JButton("Bye Bye!");
exitButton.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.exit(0);
}
});
mainPanel.add(exitButton, BorderLayout.SOUTH);
}
public static void main(String... args) {
LayeredSwingDemo swingDemo = new LayeredSwingDemo("Swing Demo Window");
swingDemo.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
swingDemo.setSize(new Dimension(500, 500));
swingDemo.setVisible(true);
}
}
Listing 10-10Swing Application using BorderLayout
to Arrange Components
在图 10-5 中你可以看到修改后的应用。我们已经在内容窗格的南部区域添加了一个退出按钮,并为BorderLayout的整体区域安排加了下划线。
图 10-5
边界布局区域
此外,因为 new 按钮必须是退出应用的唯一方式,所以
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
被替换为
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
按钮附带了一个java.awt.event.ActionListener实例,这样它就可以记录按钮被点击的事件并做出相应的反应,在本例中是退出应用。大多数 Swing 组件支持侦听器,可以定义侦听器来捕获用户在对象上执行的事件,并以某种方式做出反应。我们可以看到,按钮扩展并填充了该区域的整个空间,因为它继承了该区域的维度。为了避免这种情况,按钮应该放在另一个容器中,这个容器应该使用不同的布局:FlowLayout。顾名思义,这种布局允许在定向流中添加 Swing 组件,就像在段落中一样。可以像文本文档中的文本格式一样进行调整,并为要对齐的组件定义常量:居中、左对齐等等。在前面的例子中,我们将把exitButton包装在另一个利用FlowLayout的JPanel中。清单 10-11 展示了如何使用FlowLayout在JFrame实例的右上角放置一个按钮。
...
public LayeredSwingDemo(String title) {
super(title);
mainPanel = (JPanel) this.getContentPane();
exitButton = new JButton("Bye Bye!");
exitButton.addActionListener(e -> System.exit(0));
JPanel exitPanel = new JPanel();
FlowLayout flowLayout = new FlowLayout();
flowLayout.setAlignment(FlowLayout.RIGHT);
exitPanel.setLayout(flowLayout);
exitPanel.setComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT);
exitPanel.add(exitButton);
mainPanel.add(exitPanel, BorderLayout.SOUTH);
}
...
Listing 10-11Swing Application Using BorderLayout and FlowLayout to Arrange Components
还可以使用更多的布局,但是让我们通过添加一个包含许多条目的列表来完成应用,并向其中添加一个侦听器,这样当您单击时,一个元素就会添加到添加到框架中心的文本区域中。swing 列表可以通过实例化JList<T>类来创建。这将创建一个显示对象列表的对象,并允许用户选择一个或多个项目。swing JList<T>类包含一个类型为ListModel<T>的字段,用于管理列表显示的数据内容。当创建和添加元素时,每个对象都与一个索引相关联,当用户选择一个对象时,该索引也可以用于处理。在下一个代码片段中,声明并初始化了JList对象,一个 ListSelectionListener 与之相关联,以定义当从列表中选择一个元素时要执行的操作。在我们的例子中,元素值必须加到一个JTextArea中。清单 10-12 中描述了这个对象。
private static String[] data = {"John Mayer", "Frank Sinatra",
"Seth MacFarlane", "Nina Simone", "BB King", "Peggy Lee"};
private JList<String> list;
private JTextArea textArea;
...
textArea = new JTextArea(50, 10);
//NORTH
list = new JList<>(data);
list.addListSelectionListener(new ListSelectionListener() {
@Override
public void valueChanged(ListSelectionEvent e) {
if (!e.getValueIsAdjusting()) {
textArea.append(list.getSelectedValue() + "\n");
}
}
});
mainPanel.add(list, BorderLayout.NORTH);
//CENTER
JScrollPane txtPanel = new JScrollPane(textArea);
textArea.setBackground(Color.LIGHT_GRAY);
mainPanel.add(txtPanel, BorderLayout.CENTER);
...
Listing 10-12Swing Application Using Layouts and JTextArea
to Arrange Components
当点击一个列表元素时,会发生两件事:前一个元素被取消选择,最近被点击的元素被选中,所以被选中的元素会改变。getValueIsAdjusting()方法返回这是否是一系列多个事件中的一个(选择事件,点击事件,任何支持的事件),其中更改仍在进行,我们测试该方法是否返回 false 以检查选择是否已经进行,因此我们可以获取当前选择的元素的值并将其添加到文本区域。
关于JTextArea实例,这个实例被添加到一个JScrollPane实例中,这个实例允许textArea的内容仍然可见,因为它通过提供一两个滚动条来填充文本,这取决于配置。JScrollPane也可以包装在一个包含太多条目的列表中,以确保所有条目都是可访问的。此外,由于我们对用户通过文本区域提供的输入不感兴趣,所以调用了setEditable(false);方法。
既然我们已经有了一个更复杂的应用,那么是时候改变应用的外观了。到目前为止,我们一直使用底层操作系统提供的默认设置。使用 Swing,可以将外观配置为 JDK 支持的默认外观之一,或者可以使用额外的自定义外观,这些外观作为项目类路径中的依赖项提供,或者开发人员可以创建自己的外观。为了明确地指定外观,在创建任何 swing 组件之前,必须在 main 方法中添加以下代码行:
UIManager.setLookAndFeel(..).
该方法接收一个String值作为参数,该值代表适当的外观子类的完全限定名。这个类必须扩展抽象javax.Swing.LookAndFeel。虽然没有必要,但是您可以通过调用以下命令来明确指定您想要使用本机 GUI:
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
了解了这些,我们来做一些有趣的事情。UIManager类包含实用方法和嵌套类,用于管理 swing 应用的外观。其中一个方法是getInstalledLookAndFeels(),它提取支持的外观列表,并将其作为LookAndFeelInfo[]返回。了解了这一点,让我们做以下事情:列出所有支持的外观,将它们添加到我们的列表中,当用户选择其中一个时,让我们应用它们。不幸的是,由于现在很少使用 swing,所以在我们的应用中没有太多可以使用的自定义外观,所以唯一要做的就是使用 JDK 的产品。清单 10-13 中的代码用外观和感觉完全限定的类名初始化数据数组。
private static String[] data;
...
public static void main(String... args) throws Exception {
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
UIManager.LookAndFeelInfo[] looks = UIManager.getInstalledLookAndFeels();
data = new String[looks.length];
int i =0;
for (UIManager.LookAndFeelInfo look : looks) {
data[i++] = look.getClassName();
}
SwingDemo swingDemo = new SwingDemo("Swing Demo Window");
swingDemo.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
swingDemo.setSize(new Dimension(500, 500));
swingDemo.setVisible(true);
}
...
Listing 10-13Code Sample to Initialize the List of Supported Look-and-Feels
现在ListSelectionListener的实现变得有点复杂,因为在选择了一个新的外观和感觉类后,我们必须调用JFrame实例上的repaint()来应用新的外观和感觉,所以我们将把声明放到它自己的类中,并提供SwingDemo对象作为参数,这样就可以在valueChanged(..)方法中调用repaint()。清单 10-14 中描述了代码片段。
private class LFListener implements ListSelectionListener {
private JFrame parent;
public LFListener(JFrame swingDemo) {
parent = swingDemo;
}
@Override
public void valueChanged(ListSelectionEvent e) {
if (!e.getValueIsAdjusting()) {
textArea.append(list.getSelectedValue() + "\n");
try {
UIManager.setLookAndFeel(list.getSelectedValue());
Thread.sleep(1000);
parent.repaint();
} catch (Exception ee) {
System.err.println(" Could not set look and feel! ");
}
}
}
}
Listing 10-14Code Sample Showing repaint() Being Called
如果我们运行修改后的程序,并逐个选择列表中的每一项,我们应该会看到窗口外观有一点变化。在图 10-6 中你可以看到所有并排的窗口;差异几乎不明显,但它们确实存在。
图 10-6
JDK 提供不同的外观和感觉
这就是用几行代码就可以对 Swing 组件做的事情。Swing 库中有更多的组件,但是 Swing 已经不那么常用了。因为现在的焦点是在移动和网络应用上,所以这部分就到此为止了。如果您需要创建或维护一个 Swing 应用,Oracle 提供了相当丰富的教程,其中包含大量示例,您可以直接复制粘贴并根据需要进行修改。 1
JavaFX 简介
JavaFX Script 是 Sun Microsystems 设计的脚本语言,是 Java 平台上 JavaFX 技术家族的一部分。它是在 2008 年 12 月 JDK 6 发布后不久发布的,有一段时间开发者预计它会被放弃,因为它真的没有那么流行,是一种完全不同的语言。在收购 Sun Microsystems 之后,Oracle 决定保留它,并将其转变为 JavaFX 库,Java FX 库是一组图形和媒体包,开发人员可以使用它们来设计、创建、测试、调试和部署能够跨不同平台(包括移动平台)一致运行的富客户端应用。JavaFX 旨在取代 Swing 成为 JDK 的主要 GUI 库,但到目前为止,Swing 和 Java FX 都是所有 JDK 版本的一部分,直到 10。这在 JDK 11 中改变了。从 JDK 11 开始,JavaFX 作为一个独立的模块提供,与 JDK 分离。JavaFX 的使用仍然没有 Oracle 希望的那么多,将它从 JDK 中分离出来可能会鼓励 OpenJFX 社区贡献一些创新的想法,这些想法可能会将这个库转变为市场上其他现有 GUI 工具包的实际竞争对手(例如,Eclipse SWT)。 2
在被 JDK 排除后,Java FX 已经独立发展,与发布的 Java 版本保持同步。在撰写本章时,有一个 Java FX 17 EAP 版本可以在 https://openjfx.io 下载。
下载适合您系统的版本后,解压归档文件。里面至少应该有一个 legal 和 lib 目录。lib 目录包含打包成 JAR 文件的 JavaFX 二进制文件。根据操作系统的不同,lib 可能包含其他库文件。对于本章中的示例,您必须复制以下三个文件:javafx.base.jar、javafx.controls.jar 和 chapter10/using-javafx/libs 中的 javafx.graphics.jar。
在一些电脑上,比如新的 macOS 笔记本电脑,这些例子可能无法运行,因为一些库文件必须复制到一个特定的位置。要找出复制它们的位置,运行带有
-Dprism.verbose=true VM 参数的主JavaFxDemo类。这将导致错误日志更加详细,并告诉您库文件必须复制到哪里。
例如,对于 macOS,目录是/Users/[user]/Library/Java/Extensions,要复制的文件是来自javafx-sdk-17/lib目录的所有扩展名为dylib的文件。
Java FX 曾经是 JDK 的一部分,所以它有类和其他组件。Java FX 代码目前是普通的 Java 代码,所以不再编写脚本。Java FX 组件是在一列java.fx模块下定义的。在下面的配置片段中,你可以看到我们项目中使用 Java FX 的模块通过在清单 10-15 中使用requires指令声明了它对几个java.fx模块的依赖。
module chapter.ten.javafx {
requires javafx.base;
requires javafx.graphics;
requires javafx.controls;
opens com.apress.bgn.ten to javafx.graphics;
}
Listing 10-15Configuration Sample for a Project Using java.fx Modules
Java FX Application launcher 使用反射来启动应用,因此我们需要打开com.apress.bgn.ten包来允许使用opens指令进行反射。如果没有这个指令,就会抛出一个java.lang.IllegalAccessException,应用不会启动。
最容易的开始是一个简单的窗口,只有一个关闭选项和解释。清单 10-16 中描述了显示一个普通方形窗口的代码。
package com.apress.bgn.ten;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class JavaFxDemo extends Application {
public static void main(String... args) {
launch(args);
}
@Override
public void start(Stage primaryStage) {
primaryStage.setTitle("Java FX Demo Window!");
StackPane root = new StackPane();
primaryStage.setScene(new Scene(root, 500, 500));
primaryStage.show();
}
}
Listing 10-16Simple JavaFx Application
你需要知道的第一件事是应用的主类必须扩展javafx.application.Application类,因为这是 Java FX 应用的入口点。这是必需的,因为 JAVA FX 应用是由位于 JVM 之上的名为 Prism 的新性能图形引擎运行的。除了图形引擎 Prism,Java FX 还自带名为 Glass 的窗口系统、媒体引擎和网络引擎。它们不公开暴露;开发人员唯一可用的是 Java FX API,它提供了对任何组件的访问,您可能需要这些组件来构建具有漂亮接口的应用。所有这些引擎都通过 Quantum 工具包连接在一起,该工具包是这些引擎和栈中上层之间的接口。Quantum 工具包是管理执行线程和渲染的工具。
launch(..)方法是Application类中的静态方法,用于启动独立的应用。它通常从 main 方法中调用,并且只能调用一次,否则会抛出一个java.lang.IllegalStateException。直到通过关闭所有窗口或调用Platform.exit()退出应用,launch 方法才返回。launch 方法创建一个 JavaFxDemo 实例,在其上调用init()方法,然后调用start(..)。start(..)方法在Application类中被声明为抽象的,所以开发者被迫提供一个具体的实现。
Java FX 应用是使用在javafx.scene下定义的组件构建的,具有层次结构。javafx.scene包的核心类是javafx.scene.Node,它是Scene层次结构的根。该层次结构中的类为应用用户界面的所有可视元素提供实现。因为它们都有Node作为根类,可视元素被称为节点,这使得应用成为节点的场景图,并且这个图的初始节点被称为根。每个节点都有一个唯一的标识符、一个样式类和一个包围体,除了根节点之外,图中的每个节点都有一个父节点和零个或多个子节点。除此之外,节点还具有以下属性:
-
当您将鼠标悬停在界面上以确保您单击了正确的组件时,模糊和阴影等效果非常有用。
-
不透明。
-
改变视觉状态或位置的变换。
-
事件处理程序类似于 Swing 中的监听器,用于定义对鼠标、按键和输入法的反应。
-
特定于应用的状态。
场景图大大简化了丰富界面的构建,因为它还包括矩形、文本、图像和媒体等基本图形,动画各种图形可以通过包javax.animation的动画 API 来完成。如果你对 Java FX 有兴趣,这里有一篇关于它的详细文章: https://docs.oracle.com/javafx/2/architecture/jfxpub-architecture.htm 。这本书的重点是如何做事情,而不是现在他们的工作,所以阅读这篇文章可能有助于你未来解决方案的设计。
我们从一个简单的窗口开始。第一步是添加一个退出应用的按钮。由于渲染 Java FX 应用涉及到渲染引擎,这意味着它必须正常关闭,所以调用System.exit(0)并不是一个好的选择。start(..)方法的内容必须调用一个特殊的 JavaFX 方法来优雅地关闭应用。代码如清单 10-17 所示。
package com.apress.bgn.ten;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.*;
import javafx.stage.*;
public class SimpleJavaFxDemo extends Application {
public static void main(String... args) {
launch(args);
}
@Override
public void start(Stage primaryStage) {
primaryStage.setTitle("Java FX Demo Window!");
Button btn = new Button();
btn.setText("Bye bye! ");
btn.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
Platform.exit();
}
});
StackPane root = new StackPane();
root.getChildren().add(btn);
primaryStage.setScene(new Scene(root, 300, 300));
primaryStage.show();
}
}
Listing 10-17Simple JavaFx Application with a Button
运行SimpleJavaFxDemo类会在你的屏幕上弹出如图 10-7 所示的窗口,如果你点击Bye, bye!按钮,应用会因为Platform.exit()调用而优雅地关闭。
图 10-7
JavaFX 窗口演示
这个按钮只是放在窗口里面,默认情况下放在中间,因为没有编写代码来定位它。Java FX 支持以类似于 Swing 的方式在窗口中排列节点 3 ,但是 Java FX 提供了支持几种不同风格布局的布局窗格。JavaFX 中带有BorderLayout管理器的JPanel的等价物是一个名为BorderPane的内置布局名称。BorderPane提供了五个放置节点的区域,分布与BorderLayout相似,但名称不同。清单 10-18 显示了将我们的按钮放置在右下角底部区域的代码,然后讨论更多关于它的内容。
package com.apress.bgn.ten;
import javafx.application.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.*;
import javafx.stage.*;
public class PannedJavaFxDemo extends Application {
public static void main(String... args) {
launch(args);
}
@Override
public void start(Stage primaryStage) {
primaryStage.setTitle("Java FX Demo Window!");
Button exitButton = new Button();
exitButton.setText("Bye bye! ");
exitButton.setOnAction(event -> Platform.exit());
BorderPane borderPane = new BorderPane();
HBox box = new HBox();
box.setPadding(new Insets(10, 12, 10, 12));
box.setSpacing(10);
box.setAlignment(Pos.BASELINE_RIGHT);
box.setStyle("-fx-background-color: #85929e;");
box.getChildren().add(exitButton);
borderPane.setBottom(box);
StackPane root = new StackPane();
root.getChildren().add(borderPane);
primaryStage.setScene(new Scene(root, 500, 500));
primaryStage.show();
}
}
Listing 10-18Simple JavaFx Application with a Properly Positioned Button
运行PannedJavaFxDemo类导致图 10-8 中描述的窗口在你的屏幕上弹出,该图已经被修改以显示一个BorderPane的区域。
图 10-8
带有BorderPane演示的 JavaFX 窗口
如您所见,决定按钮位置的方法与 Swing 相似,只是有一些不同。BorderPane有五个区域,分别命名为:Top、Bottom、Center、Left和Right。为了在每个区域中放置一个节点,已经为每个区域定义了一个set*(..)方法:setTop(..)、setBottom(..)、setCenter(..)、setLeft(..)和setRight(..)。为了进一步定制节点的位置,它应该被放置在一个HBox节点中,另一个JavaFX元素可以被非常广泛地定制。从代码中可以看出,我们正在使用 CSS 样式元素设置背景。我们通过使用类Insets的一个实例定制其中的节点和包含节点的边界之间的空间,并通过调用box.setAlignment(Pos.BASELINE_RIGHT)定制包含节点的对齐方式。还有更多HBox支持的东西,所以你能用一个盒子做的事情(大部分)只受你想象力的限制。
因此,除了在前面的代码示例中制作漂亮的代码之外,还做了以下事情:根节点成为了一个BorderPane节点的父节点,在BorderPane的底部区域添加了一个HBox,这个HBox实例成为了一个Button的父节点。正如您所看到的,这个组织是分层的,按钮是层次结构中的最后一个节点。
我们还通过正确设计HBox节点的样式来避免使用层窗格。
现在是时候向我们的应用添加最后一个功能了,即文本区域和带有可选元素的列表。选中后,该值将被添加到文本区域。在 JavaFX 中创建文本区域很简单。这个类有一个非常明显的名字:TextArea。我们可以直接在BorderPane的中心区域添加节点,因为 JavaFX 文本区域默认是可滚动的。所以没有必要把它放在一个ScrollPane中,尽管这个类确实存在于javafx.scene.control包中,并且对于显示它内部的节点很有用,这些节点构成了一个比窗口大的窗体。清单 10-19 中的三行代码创建了一个TextArea类型的节点,声明它不可编辑,并将其添加到BorderPane的中心区域。清单 10-19 中的代码显示了完成这项工作的代码。
TextArea textArea = new TextArea();
textArea.setEditable(false);
borderPane.setCenter(textArea);
Listing 10-19Creating and Configuring a JavaFX TextArea
下一个是名单。列表稍微复杂一点,但也更有趣,因为通过使用 JavaFX,您可以对列表做很多事情。需要实例化以创建列表对象的类被命名为ComboBox<T>。这个类只是用来创建列表的更大的类家族中的一个,根类是抽象类ComboBoxBase<T>。根据列表的预期行为,如果我们希望支持单选或多选,如果我们希望列表可编辑或不可编辑,应该选择适当的实现。在我们的例子中,ComboBox<T>类符合以下要求:我们需要一个支持单元素部分的不可编辑列表。一个ComboBox<T>有一个返回当前用户输入的valueProperty()方法。当列表可编辑时,用户输入可以基于从下拉列表中的选择或由用户手动提供的输入。清单 10-20 展示了如何在BorderPane的顶部添加一个列表,并添加一个监听器来记录所选的值,并保存到我们之前声明的TextArea中。
import javafx.scene.control.ComboBox;
...
private static String data = {"John Mayer", "Frank Sinatra",
"Seth MacFarlane", "Nina Simone", "BB King", "Peggy Lee"};
...
ComboBox<String> comboBox = new ComboBox<>();
comboBox.getItems().addAll(data);
borderPane.setTop(comboBox);
comboBox.valueProperty().addListener(
new ChangeListener<String>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
textArea.appendText(newValue + "\n");
}
});
Listing 10-20Creating and Configuring a JavaFX ComboBox<T>
ComboBox<T>值字段是一个ObservableValue<T>实例。侦听器被添加到这个实例中,当它的值发生变化并且它的changed(..)方法被调用时,它会得到通知。如您所见,changed(..)方法也接收前面的列表选择值作为参数,因为可能我们有一些逻辑需要这两者。
在 AWT 和 Swing 中,您无法直观地处理列表。你有那种外观和感觉,就是这样。JavaFX 支持更多的节点可视化定制,因为它甚至支持 CSS。这就是为什么在下一部分我们会让我们的ComboBox<T>列表变得有趣。在 Java FX 中,列表中的每个条目都是一个单元格,可以用不同的方式绘制。为此,我们必须向这个类添加一个CellFactory<T>,它将为列表中的每一项创建一个ListCell<T>的实例。
如果没有指定CellFactory<T>,将使用默认样式创建单元格。清单 10-21 显示了定制一个ComboBox<T>的代码。
comboBox.setCellFactory(
new Callback<>() {
@Override
public ListCell<String> call(ListView<String> param) {
return new ListCell<>() {
{
super.setPrefWidth(200);
}
@Override
public void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (item != null) {
setText(item);
if (item.contains("John") || item.contains("BB")) {
setTextFill(Color.RED);
} else if (item.contains("Frank") || item.contains("Peggy")) {
setTextFill(Color.GREEN);
} else if (item.contains("Seth")) {
setTextFill(Color.BLUE);
} else {
setTextFill(Color.BLACK);
}
} else {
setText(null);
}
}
};
}
});
Listing 10-21Creating and Customizing Colors of Cells of a JavaFX ComboBox<T>
javafx.util.Callback接口是一个实用的工具接口,每次在某个动作之后,需要回调的时候都可以使用。在这种情况下,在将一个String值添加到ComboBox<T>节点的ListView(ListView顾名思义是可视化的,显示水平或垂直项目列表的ComboBox<T>的接口类型)之后,创建了一个单元格,并在那里插入了一些逻辑,以根据其值决定单元格中描述的文本的颜色。
在ListCell<T>声明中,有一段代码似乎不合适。
{
super.setPrefWidth(200);
}
前面的代码块是一种有趣的方式,可以在匿名类的声明中从父类调用方法。这里调用setPrefWidth(200)是为了确保所有的ListCell<T>实例具有相同的大小。updateItem(..)中的逻辑非常明显,因此不需要任何扩展的解释。添加细胞工厂的结果如图 10-9 所示。
图 10-9
JavaFX 彩色组合框演示
国际化
交互式应用通常被创建为部署在多个服务器上,并且在多个位置 24/7 可用。因为不是所有人都说同一种语言,所以说服人们成为你的客户并使用你的应用的关键是用多种语言构建它。设计一个应用以满足多个国家的用户需求并轻松适应这些需求的过程称为国际化。例如,以最初的谷歌页面为例。根据访问它的位置,它会根据该区域改变语言。创建帐户时,您可以选择自己喜欢的语言。这并不意味着谷歌已经为每个地区建立了一个网络应用;这是一个单一的 web 应用,根据用户的位置以不同的语言显示文本。国际化应该在应用的设计阶段就考虑到,因为以后添加它是相当困难的。我们没有 web 应用,但我们将国际化本章中迄今为止构建的 Java FX 应用。
当您开始阅读关于国际化的内容时,您可能会注意到包含国际化属性文件的文件或目录被命名为 i18n ,这是因为在英语字母表中 i 和 n 之间有 18 个字母。
国际化是基于地区的。 Locale 是一个术语,指的是语言和地区的组合。应用语言环境决定了将使用哪个国际化文件来定制应用。语言环境的概念是由 Java 中的java.util.Locale类实现的,一个Locale实例代表一个地理、政治或文化区域。当一个应用依赖于地区时,我们说它是地区敏感的,正如现在大多数应用一样。选择地区也是用户必须做的事情。每个Locale都可以用来选择相应的语言环境资源,这些资源是包含语言环境特定配置的文件。这些文件按地区分组,通常可以在resources目录下找到。这些资源用于配置java.util.ResourceBundle的一个实例,该实例可用于管理特定于地区的资源。
为了构建一个合适的本地化用例,前面的 JavaFX 应用将被修改;该列表将包含一个动物名称列表,而不是歌手姓名,这些动物名称带有可以翻译成各种语言的标签。还将添加一个包含可用语言的列表,当从该列表中选择一种语言时,将使用相应的区域设置设置一个Locale静态变量,并且窗口将被重新初始化,以便所有标签都可以被翻译成新的语言。让我们从创建资源文件开始。
资源文件是扩展名为properties的文件,顾名思义,它包含属性和值的列表。每一行都遵循下面的模式:property_name=property_value,如果不是这样,它就被认为是一个国际化资源文件。每个属性名在文件中必须是唯一的;如果有重复的,它会被忽略,IntelliJ IDEA 会用红色下划线来表示。对于需要支持的每种语言,我们需要创建一个属性文件,它包含相同的属性名,但不同的值,因为这些值将代表每种语言中该值的事务。所有文件的名称都必须包含一个通用后缀,并以语言名称和国家/地区结尾,用下划线分隔,因为这是创建语言环境实例所需的两个元素。对于我们的 JavaFX 应用,我们有三个文件,如图 10-10 所示。
图 10-10
包含三个资源文件的资源包
后缀是global,这也将是我们的资源包名称。IntelliJ IDEA 让这一点变得非常明显,它指出我们的文件是用来做什么的,并以最明显的方式描述它们。文件内容如表 10-1 所示。
表 10-1
资源文件的内容
|属性名称
|
global_en_GB 中的属性值
|
global_fr_FR 中的属性值
|
global_it_IT 中的属性值
| | --- | --- | --- | --- | | 英语 | 英语 | 英语怎么说 | 英语怎么说 | | 法语 | 法语 | 法国人 | 弗朗西丝 | | 意大利的 | 意大利的 | 义大利语 | 意大利语 | | 猫 | 猫 | 闲谈 | 高谭市 | | 狗 | 狗 | 钱 | 手杖 | | 鹦鹉 | 鹦鹉 | 钱 | 小鹦鹉 | | 老鼠 | 老鼠 | 笑一个 | 地形图 | | 母牛 | 母牛 | 鹦鹉!鹦鹉 | 牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛 | | 猪 | 猪 | Porc | 腰子 | | 窗口标题 | Java FX 演示窗口! | Java FX 演示窗口! | Java FX 演示窗口! | | 拜拜 | 拜拜! | 拜拜! | 再见! | | 选择宠物 | 选择宠物: | 选择宠物: | 选择宠物: | | 选择语言 | 选择语言: | 选择语言: | 选择语言: |
IntelliJ IDEA 可以帮助您轻松地编辑资源包文件,并通过为它们提供一个特殊的视图来确保您不会遗漏任何键。当您打开一个资源文件时,在左下角您应该看到两个选项卡:一个是命名文本,单击它允许您将属性文件作为普通文本文件进行编辑;另一个是命名资源包,单击它将打开一个特殊视图,左侧是资源文件中的所有属性名称,右侧是包含所选属性名称值的所有资源文件的视图。在图 10-11 中,您可以看到这个视图和属性 ChooseLanguage 的值。
图 10-11
资源包 IntelliJ 想法编辑器
属性名可以包含特殊字符,如下划线和点来分隔它们的各个部分。在本书的例子中,属性名很简单,因为我们只有很少的属性名。在更大的应用中,属性名通常包含一个与其用途相关的前缀;例如,如果属性值是一个标题,该名称将带有前缀title。我们文件中的属性名可以被更改为清单 10-22 中列出的名称。
English --> label.lang.english
French --> label.lang.french
Italian --> label.lang.italian
Cat --> label.pet.cat
Dog --> label.pet.dog
Parrot --> label.pet.parrot
Mouse --> label.pet.mouse
Cow --> label.pet.cow
Pig --> label.pet.pig
WindowTitle --> title.window
Byebye --> label.button.byebye
ChoosePet --> label.choose.pet
ChooseLanguage --> label.choose.language
Listing 10-22Recommended Internationalization Property Names
既然我们已经介绍了资源文件应该如何编写,那么让我们看看如何使用它们。要创建一个ResourceBundle,我们首先需要一个场所。应用有一个默认的区域设置,可以通过调用Locale.getDefault()来获得,一个ResourceBundle实例可以通过使用一个包名和一个区域设置实例来获得,如下面的代码片段所示:
Locale locale = Locale.getDefault();
ResourceBundle labels = ResourceBundle.getBundle("global", locale);
当获得一个有效的ResourceBundle时,可以用它来替换所有硬编码的String实例,调用从匹配所选区域设置的资源文件中返回文本值。所以每次我们需要为一个节点设置标签时,我们不使用实际的文本,而是使用对:resourceBundle.getString("[property_name]")的调用来获取本地化的文本。
重新加载 JavaFX 窗口时,会重新创建它的所有节点。为了能够影响方式,我们需要添加几个静态属性来保持所选的区域设置。对于我们到目前为止构建的应用,在将其国际化后,代码看起来如清单 10-23 所示。
package com.apress.bgn.ten;
import javafx.application.*;
import javafx.geometry.*;
import javafx.scene.*;
import javafx.stage.*;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Locale;
import java.util.ResourceBundle;
public class JavaFxDemo extends Application {
private static final String BUNDLE_LOCATION = "chapter10/using-javafx/src/main/resources";
private static ResourceBundle resourceBundle = null;
private static Locale locale = new Locale("en", "GB");
private static int selectedLang = 0;
public static void main(String... args) {
Application.launch(args);
}
@Override
public void start(Stage primaryStage) throws Exception {
loadLocale(locale);
primaryStage.setTitle(resourceBundle.getString("WindowTitle"));
String[] data = {resourceBundle.getString("Cat"),
resourceBundle.getString("Dog"),
resourceBundle.getString("Parrot"),
resourceBundle.getString("Mouse"),
resourceBundle.getString("Cow"),
resourceBundle.getString("Pig")};
BorderPane borderPane = new BorderPane();
//Top
final ComboBox<String> comboBox = new ComboBox<>();
comboBox.getItems().addAll(data);
final ComboBox<String> langList = new ComboBox<>();
String[] languages = {
resourceBundle.getString("English"),
resourceBundle.getString("French"),
resourceBundle.getString("Italian")};
langList.getItems().addAll(languages);
langList.getSelectionModel().select(selectedLang);
GridPane gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
Label labelLang = new Label(resourceBundle.getString("ChooseLanguage"));
gridPane.add(labelLang, 0, 0);
gridPane.add(langList, 1, 0);
Label labelPet = new Label(resourceBundle.getString("ChoosePet"));
gridPane.add(labelPet, 0, 1);
gridPane.add(comboBox, 1, 1);
borderPane.setTop(gridPane);
//Center
final TextArea textArea = new TextArea();
textArea.setEditable(false);
borderPane.setCenter(textArea);
comboBox.valueProperty().addListener((observable, oldValue, newValue)
-> textArea.appendText(newValue + "\n"));
langList.valueProperty().addListener((observable, oldValue, newValue)
-> {
int idx = langList.getSelectionModel().getSelectedIndex();
selectedLang = idx;
if (idx == 0) {
//locale = Locale.getDefault();
new Locale("en", "GB");
} else if (idx == 1) {
locale = new Locale("fr", "FR");
} else {
locale = new Locale("it", "IT");
}
primaryStage.close();
Platform.runLater(() -> {
try {
new JavaFxDemo().start(new Stage());
} catch (Exception e) {
System.err.println("Could not reload application!");
}
});
});
HBox box = new HBox();
box.setPadding(new Insets(10, 12, 10, 12));
box.setSpacing(10);
box.setAlignment(Pos.BASELINE_RIGHT);
box.setStyle("-fx-background-color: #85929e;");
Button exitButton = new Button();
exitButton.setText(resourceBundle.getString("Byebye"));
exitButton.setOnAction(event -> Platform.exit());
box.getChildren().add(exitButton);
borderPane.setBottom(box);
//Bottom
StackPane root = new StackPane();
root.getChildren().add(borderPane);
primaryStage.setScene(new Scene(root, 500, 500));
primaryStage.show();
}
private void loadLocale(Locale locale) throws Exception {
File file = new File(BUNDLE_LOCATION);
URL[] url = {file.toURI().toURL()};
ClassLoader loader = new URLClassLoader(url);
resourceBundle = ResourceBundle.getBundle("global", locale, loader);
}
}
Listing 10-23JavaFX Internationalized Application
您可能想知道为什么我们使用另一种方式加载资源包,以及为什么使用包位置的完整相对路径。如果我们希望应用可以从 IntelliJ 接口运行,我们必须提供一个相对于应用执行上下文的路径。当应用在可运行的 Java 档案中构建和打包时,资源文件是它的一部分,位于类路径中。当通过在 Java IDE 中执行main()方法来运行应用时,类路径相对于项目的实际位置。
清单 10-24 中的代码片段通过关闭Stage来重启场景,然后实例化一个 JavaFxDemo 对象并调用start(..)。这意味着整个层次节点结构被重新创建,唯一保留的状态是静态对象中定义的状态。这是区域设置所需要的,因为start(..)方法的执行现在从调用loadLocale(locale)开始,?? 选择应用的区域并加载ResourceBundle,这样所有节点都可以用它返回的文本进行标记。
primaryStage.close();
Platform.runLater(() -> {
try {
new JavaFxDemo().start(new Stage());
} catch (Exception e) {
System.err.println("Could not reload application!");
}
});
Listing 10-24JavaFX Code Snippet to Restart the Scene
到目前为止,我们构建并使用的应用非常简单。如果您需要构建更复杂的接口,并且需要国际化,这将意味着需要配置更多的翻译。您可能需要不同数字和日期格式的文件,或者多个资源包。国际化是一个很大的话题,也是一个很重要的话题,因为现在很少构建一个应用在一个地区使用。对于一个 Java 初学者来说,知道支持类是什么以及如何使用它们是一个很好的起点。
构建 Web 应用
现在,我们正在构建一个 web 应用。web 应用是运行在服务器上的应用,可以使用浏览器进行访问。直到最近,大多数 Java 应用都需要像 Apache Tomcat 或 Glassfish 这样的 Web 服务器,或者像 JBoss(目前称为 WildFly)或 TomEE 这样的企业服务器来托管,以便可以访问它们。您将使用类和 HTML 或 JSP 文件编写 web 应用,将其打包在 WAR (Web 归档)或 EAR(企业归档)中,将其部署到服务器,然后启动服务器。服务器将提供应用的上下文,并将请求映射到提供答案作为响应的类。假设应用将部署在 Tomcat 服务器上,在图 10-12 中,您可以看到一个已部署应用功能的抽象模式。
图 10-12
部署在 Apache Tomcat 服务器上的 Web 应用
对 web 应用的请求可以来自浏览器以外的其他客户端(例如,移动应用),但是因为本节涵盖了 web 应用,所以我们将假设对我们的应用的所有请求都来自浏览器。
先稍微解释一下互联网。互联网是一个信息系统,由许多连接在一起的计算机组成。一些计算机托管提供对应用的访问的应用服务器,一些计算机访问这些应用,而一些计算机两者都做。这些计算机之间的通信是通过一系列协议在网络上完成的:HTTP、FTP、SMTP、POP 等等。最流行的协议是 HTTP,代表超文本传输协议,它是一种不对称的请求-响应客户端-服务器协议,这意味着客户端向服务器发出请求,然后服务器发送响应。后续的请求彼此不了解,它们不共享任何状态,因此它们是无状态的。HTTP 请求可以有不同的类型,根据它们要求服务器上的应用执行的动作来分类,但是有四种类型是开发人员更常用的(图 10.12 中的请求箭头中列出的一种)。我们不会深入讨论关于请求组件的细节,因为这与 Java 并不真正相关,但是我们将只讨论足够多的细节来理解 web 应用是如何工作的。下面列出了四种请求类型和服务器为每一种请求返回的响应类型。
图 10-13
Firefox 中的网络调试器视图
- GET :每当用户在浏览器中输入一个 URL,比如
http://my-site.com/index.html,浏览器就把这个地址转换成一个请求消息,发送给 web 服务器。在 Firefox 中打开调试器视图,点击网络标签,尝试访问https://www.google.com/,就可以轻松查看浏览器做了什么。在图 10-13 中,您可以看到 Firefox 调试器视图显示了被请求的 URL 和请求消息的内容。
在图像的右边,您可以看到被请求的 URL、请求的类型(也称为请求方法,在本例中是 GET)以及请求被发送到的服务器的远程地址。还有一个名为 Raw headers 的按钮,它将打开一个视图,以文本形式显示请求和响应的内容。GET 请求用于从服务器中检索某些东西,在本例中是一个网页。如果可以找到该网页,则发送包含浏览器要显示的页面和其他属性(如状态代码)的响应,以表明一切正常。有一个 HTTP 状态代码列表,最重要的是 200 代码,这意味着一切正常。在前面的图像中,您可以看到,在最初的请求得到回复后,为了显示页面,完成了许多额外的请求,并且所有后续的请求都是成功的,因为服务器返回的状态放在表中的第一列,并且总是 200。
-
PUT :当数据被发送到服务器用于更新现有数据时,使用这种类型的请求。在企业应用中,PUT 请求被解释为更新现有对象的请求。该请求包含对象的更新版本和识别它的方法。成功的 PUT 请求会生成一个状态代码为 204 的响应。
-
POST :当服务器需要被指示保存数据以便存储时,使用这种类型的请求。与 PUT 请求不同的是,这个数据在服务器上还不存在。在企业应用中,POST 请求或者用于发送凭证以便对用户进行身份验证,或者用于发送将用于创建新对象的数据。当 POST 请求用于发送凭据时,当用户通过身份验证时,响应状态代码为 200;当用户凭据不良时,响应状态代码为 401(未授权);当 POST 请求用于发送要保存的数据时,如果创建了对象,则返回 201 状态代码。
-
DELETE :当要求服务器删除数据时,使用这种类型的请求。当一切正常时,响应代码是 200,如果不正常,则是与原因相关的任何其他错误代码。
在更复杂的应用中还有一些其他的 HTTP 方法。如果你对请求方法、状态码和 HTTP 基础知识更感兴趣,我很自信地推荐你看看这个教程: http://www.steves-internet-guide.com/http-basics 。
现在让我们回到编写 Java Web 应用上来。
我们已经提到,直到不久前,我们需要一个服务器来托管一个 web 应用。从几年前开始,情况就不再是这样了。随着用于测试目的的数据库和具有最低功能的应用被嵌入式数据库所取代,web 服务器也发生了同样的情况。如果您想快速编写一个简单的 web 应用,现在可以选择使用像 Jetty 或 Tomcat Embedded 这样的嵌入式服务器。用嵌入式服务器支持复杂的页面非常困难,但是嵌入式服务器通常用于只需要简单 REST APIs 的微服务。
带有嵌入式服务器的 Java Web 应用
对于本章的这一节,嵌入式 Tomcat 服务器用于显示几个简单的网页,使用 Java servlet*(耐心年轻的学徒,稍后会解释)*。使用 Tomcat 10.0.7 版本,意味着支持 Java 模块。使用嵌入式 Apache Tomcat 服务器的优点是,您可以通过执行 main 方法来运行 web 应用。
清单 10-25 中描述了代码,它声明了一个非常简单的 servlet,作为应用的主页。
package com.apress.bgn.ten;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
// other import statements omitted
public class WebDemo {
private static final Logger LOGGER = Logger.getLogger(Main.class.getName());
public static final Integer PORT = Optional.ofNullable(System.getenv("PORT")).map(Integer::parseInt).orElse(8080);
public static final String TMP_DIR = Optional.ofNullable(System.getenv("TMP_DIR")).orElse("/tmp/tomcat-tmp");
public static final String STATIC_DIR = Optional.ofNullable(System.getenv("STATIC_DIR")).orElse("/tmp/tomcat-static");
public static void main(String... args) throws IOException, LifecycleException {
Tomcat tomcat = new Tomcat();
tomcat.setBaseDir(TMP_DIR);
tomcat.setPort(PORT);
tomcat.getConnector();
tomcat.setAddDefaultWebXmlToWebapp(false);
String contextPath = ""; // root context
boolean createDirs = new File(STATIC_DIR).mkdirs();
if(createDirs) {
LOGGER.info("Tomcat static directory created successfully.");
} else {
LOGGER.severe("Tomcat static directory could not be created.");
}
String docBase = new File(STATIC_DIR).getCanonicalPath();
Context context = tomcat.addWebapp(contextPath, docBase);
addIndexServlet(tomcat, contextPath, context); // omitted
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
tomcat.getServer().stop();
} catch (LifecycleException e) {
e.printStackTrace();
}
}));
tomcat.start();
tomcat.getServer().await();
}
}
Listing 10-25Simple Java Application with an Embedded Server
例如,当您不需要利用模板库(如 JSP)生成复杂的 web 页面时,使用嵌入式 Tomcat 服务器编写应用是非常容易的。清单 10-24 中的代码片段只需要tomcat-embed-core库作为依赖项,创建服务器的步骤非常简单,在这里解释如下:
-
创建一个
org.apache.catalina.startup.Tomcat实例并选择端口来公开它。在这种情况下,它是 8080,PORT变量的默认值,除非使用同名的系统环境变量声明。 -
为
Tomcat实例设置一个基本目录,运行的服务器将在这里保存它生成的各种文件,比如日志。在这种情况下,目录被配置为/tmp/tomcat-tmp,除非使用带有TMP_DIR名称的系统环境变量进行声明。运行应用的用户应该对该位置拥有写权限。 -
设置
Tomcat的静态文件所在的目录。在这种情况下,目录被配置为/tmp/tomcat-static,除非使用带有STATIC_DIR名称的系统环境变量进行声明。运行应用的用户应该对该位置拥有写权限。 -
通过调用
tomcat.setAddDefaultWebXmlToWebapp(false)禁用Tomcat的默认配置。在这种情况下,这会阻止org.apache.jasper.servlet.JspServlet被注册。这个 servlet 支持在 webapp 中使用 JSP 文件,但是在配置时会自动接管并假设任何请求都必须解析为 JSP 页面,因此 Java Servlets 会被忽略。因为我们想保持应用的简单性并使用 Java servlets,所以我们禁用了它。 -
通过添加关闭挂钩,确保服务器在应用关闭时正常关闭。
-
编写一个简单的 servlet 来显示应用的主页,以测试服务器是否正确启动并按预期工作。这是通过前面的清单中省略的
addIndexServlet(..)方法来完成的,以确保焦点在Tomcat实例上。方法如清单 10-26 所示。
private static void addIndexServlet(Tomcat tomcat, String contextPath, Context context) {
HttpServlet indexServlet = new HttpServlet() {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
PrintWriter writer = resp.getWriter();
writer.println("<html><title>Welcome</title><body style=\"background-color:black\">");
writer.println("<h1 style=\"color:#ffd200\"> Embedded Tomcat 10.0.7 says hi!</h1>");
writer.println("</body></html>");
}
};
String servletName = "IndexServlet";
String urlPattern = "/";
tomcat.addServlet(contextPath, servletName, indexServlet);
context.addServletMappingDecoded(urlPattern, servletName);
}
Listing 10-26A Simple Method That Creates a Very Simple Servlet and Registers It with a Tomcat Instance
servlet 实例必须与一个名称和一个 URL 模式相关联,当用户试图打开serverURL/contextPath/urlPattern页面时,调用doGet(..)方法,返回在其主体中构造的响应。
部署在服务器(甚至是嵌入式服务器)上的 Java web 应用需要一个上下文路径。上下文路径值是访问应用的 URL 的一部分。URL 由四部分组成:
-
protocol:客户端和服务器进行通信所使用的应用层协议,如http、https、ftp等。 -
hostname:DNS 域名(如www.google.com)或 ip 地址(如 192.168.0.255)或网络中可识别的任何别名。例如,当从同一台计算机上访问应用时,可以使用安装在127.0.0.1、本地主机或0.0.0.0上的服务器。 -
path and filename:资源的名称和位置,在服务器文档基目录下。用户通常会请求查看服务器上托管的特定页面,这就是为什么 URL 看起来像这样:https://docs.oracle.com/index.html。出于安全原因,一种非常常用的做法是通过使用内部映射(称为 URL 重定向)来隐藏路径和文件名。
那么前面提到的contextPath值是从哪里来的呢?当我们像前面的代码示例一样声明了一个嵌入式服务器时,它托管的任何文件都可以通过使用http://localhost:8080/来访问。但是在专用服务器上,可以同时运行多个应用,必须有一种方法将它们分开,对吗?这就是contextPath值派上用场的地方。通过将上下文路径设置为/demo而不是空字符串,可以在http://localhost:8080/demo/访问WebDemo应用及其提供给用户的资源。
总之,回到 Java web 应用。Java Web 应用是动态的;使用Servlets和JSP(Java Server Pages)页面从 Java 代码生成页面。因此,Java Web 应用不是在服务器上运行,而是在服务器的 Web 容器中运行。(这就是为什么 Tomcat 或 Jetty 有时被称为 Servlet 容器。)web 容器为 Java Web 应用提供了 Java 运行时环境。Apache Tomcat 就是这样一个运行在 JVM 中的容器,它支持 servlets 和 JSP 页面的执行。一个 servlet 是一个 Java 类,是jakarta.servlet.http.HttpServlet的子类。这种类型的实例在 web 容器中响应 HTTP 请求。
Apache Tomcat 10.x 是 Jakarta EE(正式的 Java EE)技术子集的开源软件实现。Tomcat 基于 Servlet 5.0、JSP 3.0、EL 4.0、WS 2.0 和 JASIC 2.0。在 Tomcat 9.x 之前,servlet 是一个 Java 类,是
javax.servlet.http.HttpServlet的子类。Tomcat 10.x 需要从javax.包迁移到jakarta.</emphasis>,以将 Oracle 官方 Java 产品与使用 Eclipse 构建服务器构建的开源产品分开。 4
一个 JSP 页面是一个带有。包含 HTML 和 Java 代码的 jsp 扩展。JSP 页面在第一次被访问时被 web 容器编译成 servlet。本质上,servlet 是 Java Web 应用的核心元素。服务器还必须知道 servlet 的存在以及如何识别它,这就是调用tomcat.addServlet(contextPath, servletName, servlet)的来源。它基本上是将名为servletName的 servlet 添加到带有contextPath值上下文路径的应用上下文中,然后将一个 URL 模式关联到 servlet,调用context.addServletMapping(urlPattern, servletName)。
当 Java Web 应用运行时,它的所有 servlets 和 JSP 都在它的上下文中运行,但是它们必须在代码中添加到上下文中,并映射到 URL 模式。与该 URL 模式匹配的请求 URL 将访问该 servlet。在清单 10-26 中,servlet 是通过实例化HttpServlet抽象类并产生一个匿名 servlet 实例而当场创建的。清单清单 10-27 描述了一个名为SampleServlet的具体类,它扩展了HttpServlet类。这样做的好处是 URL 模式和 servlet 名称可以成为这个类的属性,从而简化了将它们添加到应用上下文的语法。
package com.apress.bgn.ten;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.logging.Logger;
public class SampleServlet extends HttpServlet {
private static final Logger LOGGER = Logger.getLogger(SampleServlet.class.getName());
private final String servletName = "sampleServlet";
private final String urlPattern = "/sample";
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
PrintWriter writer = resp.getWriter();
try {
writer.println(WebResourceUtils.readResource("index.html"));
} catch (Exception e) {
LOGGER.warning("Could not read static file : " + e.getMessage());
}
}
@Override
public String getServletName() {
return servletName;
}
public String getUrlPattern() {
return urlPattern;
}
}
Listing 10-27The SampleServlet Class
出于实际原因,将urlPattern属性添加到该类中,以便将与该 servlet 相关的所有内容保存在一个地方。servletName也是一样。如果打算多次实例化这个类来创建多个 servlets,那么这两个属性应该声明为可配置的。将这个 servlet 添加到应用非常容易。需要创建一个这种类型的对象,然后必须调用tomcat.addServlet(..)和context.addServletMappingDecoded(..),如清单 10-28 所示。
SampleServlet sampleServlet = new SampleServlet();
tomcat.addServlet(contextPath, sampleServlet.getServletName(), sampleServlet);
context.addServletMappingDecoded(sampleServlet.getUrlPattern(), sampleServlet.getServletName());
Listing 10-28Adding the SampleServlet Class to the Web Application
在doGet(..)方法中,index.html文件的内容被读取(使用WebResourceUtils,它是本章项目的一部分,但与本章无关),并使用响应PrintWriter写入响应对象。
如您所见,doGet(..)方法接收两个对象作为参数:HttpServletRequest实例被读取,从客户端发送的请求的所有内容都可以使用适当的方法和 HttpServletResponse 实例进行访问,后者用于向响应添加信息。在前面的代码示例中,我们只是编写从另一个文件读取的 HTML 代码。可以调用的额外方法是设置响应状态的response.setStatus(HttpServletResponse.SC_OK)。
除了doGet(..)方法,还有与每个 HTTP 方法匹配的do*(..)方法,它们声明相同类型的参数。
从 Servlet 3.0 开始,可以使用@WebServlet注释来编写 Servlet,这消除了显式添加到 web 应用并在清单 10-28 所示的上下文中映射的必要性,因为它们是在 Tomcat 启动时自动选取的。此外,也不需要实例化 servlet 类。
Servlet 3.0 之后的SampleServlet类如清单 10-29 所示。
package com.apress.bgn.ten;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
// other import statements omitted
@WebServlet(
name = "sampleServlet",
urlPatterns = {"/sample"}
)
public class SampleServlet extends HttpServlet {
private static final Logger LOGGER = Logger.getLogger(SampleServlet.class.getName());
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
PrintWriter writer = resp.getWriter();
try {
writer.println(WebResourceUtils.readResource("index.html"));
} catch (Exception e) {
LOGGER.warning("Could not read static file : " + e.getMessage());
}
}
}
Listing 10-29Adding the SampleServlet Class to the Web Application
这就是我们处理 servlets 的方式,但是我们如何使用嵌入式服务器处理 JSP 页面呢?不是不可能,但也不容易。这就是为什么,对于这个任务,人们通常会选择更聪明的框架,比如 Spring Boot。 5
为了避免经历大量的设置代码和细节,JSP 语法将在一个 WEB 应用上解释,该应用必须部署在一个 Tomcat 服务器的独立实例上。
独立服务器上的 Java Web 应用
设计用于部署在应用服务器上的 Java 应用被打包为 Web 档案(war)或企业应用档案(ear)。这些是特殊类型的 Java 档案(jar ),用于将其他 jar、JSP(Java 服务器页面)、Java 类、静态页面和 web 应用中的其他资源组合在一起。有一个叫maven-war-plugin的 maven 插件,把一个神器打包成一场战争。EAR 是 Jakarta EE 使用的一种文件格式,用于将一个或多个模块打包到应用服务器上的单个部署中;它基本上将一组 jar 和 wars 链接到一个应用中。
在本章中,我们构建了一个非常简单的 web 应用,打包成一个 war,包含 Java 服务器页面,并部署到一个 Apache Tomcat 服务器的独立实例中。
要在本地安装 Apache Tomcat 服务器,请访问位于 https://tomcat.apache.org/download-10.cgi 的官方网站,下载 Apache Tomcat 版本 10.0.7,并按照您的操作系统的说明进行操作。因为 Apache Tomcat 是作为一个档案文件提供的,所以安装过程应该很简单,只需在您的计算机上的某个地方打开它。在本章中,IntelliJ IDEA 使用 Tomcat launcher 来运行 web 应用,因此与服务器的交互将是最小的。
Java web 应用的结构与典型的 Java 应用不同。它包含典型的main和test目录,但也包含一个包含 web 资源的webapp目录。项目结构如图 10-14 所示。
图 10-14
Web 应用结构变化
注意位于WEB-INF目录下的web.xml文件。这个文件定义了 web 应用的结构。在 Servlet 3.0 之前,这个文件是将 Servlet 映射到 urlPatterns 并将其配置为应用的一部分的唯一方法。在 Servlet 3.0 和注释的引入之后,这个文件大部分是空的。
当构建 web 应用时,应用的字节码保存在WEB-INF/classes下。如果应用使用第三方库,它们都被保存到WEB-INF/lib中。
现在,回到 Java 服务器页面。
有两种编写 JSP 页面的方法。最简单的一种方法是使用**JSP script let,这种方法现在很少使用,因为它将 HTML 代码与 Java 代码结合在一起。**JSP script let 是使用指令标签嵌入在 HTML 代码中的 Java 代码片段。有三种类型指令标签:
-
<%@ page ...%>声明性指令用于向容器提供指令。使用此指令声明的指令属于当前页面,可以在页面中的任何位置使用。这种指令可用于导入 Java 类型或定义页面属性。示例: -
<%@ include ...%>declarative 用于在翻译阶段包含一个文件。因此,使用该指令的当前 JSP 文件是其内容和使用该指令声明的文件内容的组合。 -
<%@ include file = "footer.jsp" > -
<%@ taglib ...%>declarative 用于声明一个标签库,其中包含将在 JSP 页面中使用的元素。这个声明性很重要,因为它用于导入一个带有自定义标记和元素的库,这些标记和元素将用于编写 JSP 页面。这些标签提供动态功能,而不需要 scriptlets。
<%@ page import="java.util.Date" %>
<%@ page language="java" contentType="text/html; charset=US-ASCII" pageEncoding="US-ASCII" %>
图 10-14 中的index.jsp箭头非常简单,其内容在清单 10-30 中描述:
<%@ page import="java.util.Date" %>
<%@ page language="java" contentType="text/html; charset=US-ASCII" pageEncoding="US-ASCII" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head><title>Web Application Demo JSP Page</title></head>
<body style="background-color:black">
<h1 style="color:#ffd200"> Today is <%= new Date() %></h1>
</body>
</html>
Listing 10-30The Very Simple index.jsp Page Contents
它只打印今天的日期,并通过调用new Date()来完成。如你所见,我们在一个看起来像 HTML 的页面中使用了 Java 代码。因为那些指令在那里,扩展名是.jsp,,所以容器知道这个文件必须被编译成 servlet。当 web 应用的根域被访问时,它打开的默认页面是一个名为index.html或index.htm或index.jsp的文件。除了在WEB-INF目录中添加名为index.jsp的文件并确保容器可以找到它之外,剩下要做的就是在 IntelliJ IDEA 中配置一个 Apache Tomcat 启动器,并在启动 Tomcat 之前配置它来部署构建这个应用时产生的 war。
要配置 Apache Tomcat 启动器,IntelliJ IDEA 需要启用 Tomcat 和 TomEE 插件。如果您安装 IntelliJ IDEA 时没有对其进行自定义,则默认情况下会安装该插件。如果你设法卸载了它,只需打开 IntelliJ IDE 首选项窗口,选择插件,在市场中找到它并勾选它的复选框,如图 10-15 所示。
图 10-15
在 IntelliJ IDEA 中启用 Tomcat 和 TomEE 插件
一旦插件安装完毕,点击启动部分并选择编辑配置...,从左侧列表中选择 Tomcat 服务器➤本地,如图 10-16 所示。
图 10-16
在 IntelliJ IDEA 中创建 Apache Tomcat 启动器
一个新的对话窗口打开。点击配置按钮,选择本地 Apache Tomcat,点击确定,如图 10-17 所示。
图 10-17
在 IntelliJ IDEA 中创建 Apache Tomcat 启动器:选择 Tomcat 服务器
选择服务器后,点击修复按钮或者部署标签,点击+符号选择工件。IntelliJ IDEA 将识别项目中的所有 web 应用,并提供一个列表供选择。选择simple-webapp,如图 10-18 所示。
图 10-18
在 IntelliJ IDEA 中创建 Apache Tomcat 启动器:选择要部署的 web 应用
随意编辑启动器的名称和上下文路径,如图 10-19 所示。
图 10-19
在 IntelliJ IDEA 中创建 Apache Tomcat 启动器:选择要部署的 web 应用
这样配置启动器后,启动服务器并在浏览器中打开http://localhost:8080/demo页面。您应该会在页面上看到这样一条简单的消息:
Today is Mon Aug 20 01:41:29 BST 2018
当您自己运行该应用时,所描述的日期将是您系统上的日期。
既然已经提到了 taglibs ,那我们也来谈一点。最基本的标签库被命名为 JSTL ,代表 JSP 标准标签库。其他更先进的标记库由 JSF (Java Server Faces)、百里香或 Spring 提供。该库中定义的标签可用于编写根据请求属性改变行为的 JSP 页面,可用于迭代、测试值、国际化和格式化。基于所提供的 JSTL 函数,标签被分成五类,只有在指定了适当的指令之后,它们才能在 JSP 页面中使用。下面列出了五个指令以及标签涵盖的主题:
-
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>JSTL 核心标签提供对显示值、迭代、条件逻辑、捕捉异常、url、转发或重定向响应的支持。 -
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>JSTL 格式化标签通过地区和资源包提供数字、日期和 i18n 支持的格式化。 -
<%@ taglib uri="http://java.sun.com/jsp/jstl/sql" prefix="sql" %>JSTL SQL 标签为与关系数据库的交互提供支持,但从不在网页中使用 SQL,因为它非常容易被黑客攻击(只需在 Google 上查找术语 SQL Injection)。 -
<%@ taglib uri="http://java.sun.com/jsp/jstl/xml" prefix="x" %>JSTL XML 标签为处理 XML 文档、解析、转换和 XPath 表达式求值提供支持。 -
<%@ taglib uri="http://java.sun.com/jsp/jstl/functions" prefix="fn" %>JSTL 函数标签提供了许多函数,可用于执行文本操作等常见操作。
现在我们知道了基本的标签类别,你认为我们需要使用哪些类别来重新设计我们的index.jsp页面?如果你想过和的核心,你就对了。此外,使用标记库的 JSP 页面通常由一个 servlet 进行备份,该 servlet 为将在 JSP 页面中使用的请求设置适当的属性。所以让我们修改一下index.jsp页面,如清单 10-31 所示。
<%@ page language="java" contentType="text/html;charset=US-ASCII" pageEncoding="US-ASCII"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>Web Application Demo JSP Page</title>
</head>
<body style="background-color:black">
<fmt:formatDate value="${requestScope.today}" pattern="dd/MM/yyyy" var="todayFormatted"/>
<p style="color:#ffd200"> Today is <c:out value="${todayFormatted}" /> </p>
</body>
</html>
Listing 10-31Using FMT adn Core Taglibs to Rewrite index.jsp
现在,让我们重新命名它,以清楚它的用途:to date.jsp并编写一个名为DateServlet的 servlet 类,将today属性添加到请求中,该属性将由<fmt:formatDate>标记格式化,结果保存到todayFormatted变量中,稍后由<c:out>标记打印出来。清单 10-32 中描述了DateServlet。
package com.apress.bgn.ten;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
@WebServlet(
name = "dateServlet",
urlPatterns = {"/date"}
)
public class DateServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
System.out.println(" ->>> Getting date ");
request.setAttribute("today", new Date());
RequestDispatcher rd = getServletContext().getRequestDispatcher("/date.jsp");
rd.forward(request, response);
}
}
Listing 10-32DateServlet Class That Provides the today Attribute for the date.jsp Page
现在我们只需重启应用,第一页将显示Today is 06/07/2021,您将在系统上看到代码运行的日期。
如果你认为编写 Java web 应用很麻烦,那么你是对的。对于这样的任务,纯 Java 是相当乏味的。专业的 Java web 应用通常是使用框架编写的,这些框架可以轻松地创建页面并将其链接到后端。更有甚者,现在的趋势是使用强大的 JavaScript 框架,如 Angular 和 React,用 JavaScript 创建界面;使用高级 CSS4,许多 UI 设计现在也可以 100%在 CSS3 或 CSS4 中完成,并使用 Web 服务调用(通常是 REST)与企业服务器上托管的 Java 后端应用通信。反正好奇就去查;这个主题非常广泛,但是像 Spring 这样的框架使得设置环境和开始开发变得非常容易。只是不要陷入在不了解框架的基础上使用框架的陷阱。
摘要
本章介绍了重要的开发工具和技术,JDK 中为它们提供支持的类,以及重要的 Java 库,这些库可能会让你的开发工作变得实用而愉快。JDK 从未在 GUI 支持方面大放异彩,但 JavaFX 是从 AWT 和 Swing 发展而来的,可能会有未来。本章主题的完整列表如下:
-
如何编写交互式控制台应用
-
如何编写一个带有 Swing 界面的交互式应用
-
JavaFX 架构的基础
-
如何用 JavaFX 接口编写交互式应用
-
如何国际化您的应用
-
如何使用嵌入式服务器编写 web 应用
-
什么是 servlet
-
什么是 JSP Scriptlet
-
如何使用标记库编写 JSP 页面
-
如何将 Java web 应用部署到 Apache Tomcat
Oracle extensive Swing 教程可从 2021 年 10 月 15 日访问的 Oracle“Java 教程”, https://docs.oracle.com/javase/tutorial/uiswing/examples/layout/index.html 获得。
2
SWT 是一个用于 Java 的开源小部件工具包,旨在提供对实现它的操作系统的用户界面设施的高效、可移植的访问。你可以在 Eclipse 的官方网站上了解更多信息,“SWT:标准小部件工具包”, https://www.eclipse.org/swt ,2021 年 10 月 15 日访问。
3
前面提到过,所有 Java FX 组件的根类都被命名为 Node,所以在本节中,Java FX 组件将被称为 Node,而不是组件。
4
这篇博客文章解释了整件事:参见 Java 杂志,“从 Java EE 到 Jakarta EE 的转变”, https://blogs.oracle.com/javamagazine/transition-from-java-ee-to-jakarta-ee ,2021 年 10 月 15 日访问。
5
参见春季官方项目页面, https://spring.io/projects/spring-boot ,2021 年 10 月 15 日访问。
**
十一、使用文件
软件最重要的功能之一是信息组织和存储,目的是使用和共享信息。信息写在纸上,存储在现实生活中有组织的柜子里,可以从那里检索。软件应用也做类似的事情。信息被写在文件中,文件被组织在目录中,最终甚至在更复杂的结构中,被命名为数据库。Java 提供了从文件和数据库读取信息的类,以及写入文件和向数据库写入信息的类。在前面的章节中已经提到了数据库,在第章 9 中,介绍了一个使用 Derby 内存数据库的简单例子,向您展示像数据库这样的严重依赖是如何被模拟的,以允许单元测试。本章不是关于使用数据库,因为编写 Java 应用来使用数据库需要安装额外的软件。相反,这一章着重于读写文件,以及有多少种方法可以做到这一点。
Java IO 和 NIO APIs
在开始向您展示如何读写文件之前,我们需要向您展示如何从代码中访问它们,如何检查它们是否存在,检查它们的大小并列出它们的属性,等等。Java 中用于文件处理的核心包被命名为java.io和java.nio. 1 包名很好地暗示了它们包含的组件。java.io是 Java 输入/输出和分组组件的缩写,旨在通过数据流和序列化促进访问文件系统的输入和输出操作。java.nio是 Java 非阻塞输入/输出的缩写。这个包是在版本 1.4 中引入的,是一个 Java 编程语言 API 的集合,为密集的 I/O 操作提供特性。在 JDK 1.7 中增加了一个名为java.nio.file的包,其中包含一组实用程序类,为文件 I/O 和访问文件系统提供全面的支持。
Java NIO 和 IO 的主要区别在于 IO 是面向流的,而 NIO 是面向缓冲区的。这意味着对于旧的 Java IO API,文件是一次从一个流中读取一个或多个字节。字节不在任何地方缓存,流遍历是单向的。所以一旦溪流枯竭,就没有办法再穿越了。如果需要双向遍历流,数据必须首先存储在缓冲区中。
使用 Java NIO,数据被直接读入缓冲区,这意味着字节被缓存在 web 浏览器中,并且浏览器支持双向操作。这在处理过程中提供了更多的灵活性,但是需要额外的检查来确保缓冲区包含处理所需的所有数据。
第二个主要区别是 Java IO 操作是阻塞的。一旦调用了读取或写入文件的方法,线程就会被阻塞,直到不再有数据要读取或数据被完全写入。
Java NIO 操作是非阻塞的。线程可以经由开放通道从资源(例如,文件)请求数据,并且仅获得当前可用的数据,或者如果当前没有数据可用,则什么都不得到。线程可以先做些别的事情,然后检查数据缓冲区是否被填充,而不是等到有了一些数据才开始。
第三个区别与其说是区别,不如说是 Java NIO 额外增加的东西:选择器。这些组件允许一个线程监视多个输入通道,并只选择那些有可用数据的通道进行处理。相比之下,传统的 Java IO 则不能做到这一点,因为在文件操作完成之前,线程会一直阻塞。
根据您试图解决的问题,您可以使用其中的一种,但这都是从一个文件处理程序开始的。
文件处理程序
在 Java 中处理文件时最重要的类是java.io.File class。这个类是文件和目录路径名的抽象表示。这个类的实例被命名为文件处理程序,因为它们允许开发者使用这种类型的引用来处理 Java 代码中的文件和目录,而不是完整的路径名。可以使用不同的参数创建一个File实例。
最简单的方法是使用构造函数,它接收包含绝对文件路径名的字符串值作为参数。在清单 11-1 的代码示例中,printFileStats(..)方法用于打印文件细节。
package com.apress.bgn.eleven.io;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
public class Main {
private static final Logger log = LoggerFactory.getLogger(Main.class);
public static void main(String... args) {
// replace [workspace] with your workspace path
var file = new File("[workspace]/java-17-for-absolute-beginners/README.adoc");
printFileStats(file);
}
private static void printFileStats(File f) {
if (f.exists()) {
log.info("File Details:");
log.info("Type : {}", f.isFile() ? "file" : "directory or symlink");
log.info("Location :{}", f.getAbsolutePath());
log.info("Parent :{}", f.getParent());
log.info("Name : {}", f.getName());
double kilobytes = f.length() / (double)1024;
log.info("Size : {} ", kilobytes);
log.info("Is Hidden : {}", f.isHidden());
log.info("Is Readable? : {}", f.canRead());
log.info("Is Writable? : {}", f.canWrite());
}
}
}
Listing 11-1Printing File Details
在前面的示例中,文件处理程序实例是通过提供我的电脑上的绝对文件路径名来创建的。如果要在计算机上运行前面的代码,必须提供计算机上某个文件的路径名。如果您使用的是 Windows,请记住路径名将包含“\”字符,这是 Java 中的一个特殊字符,必须通过将它加倍来进行转义。
printFileStats(..)方法利用了许多可以在文件处理程序上调用的方法。您可以调用的方法的完整列表更大,您可以在官方 API 文档中看到它们: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/File.html 。下面的列表解释了这些方法:
- 如果路径名指向一个文件,则
isFile()返回true,如果路径名指向一个目录或一个符号链接,则返回false(一种特殊类型的文件,存在的唯一目的是链接到另一个文件,当您想要缩短文件的路径名时非常有用,在路径名长度限制为 256 个字符的 Windows 上非常有用)。在前面的代码示例中,该方法返回true,日志显示:
INFO c.a.b.e.Main - Type : file
如果我们想知道这个方法是否适用于一个目录,只需从路径名中删除文件名。
File file = new File("/[workspace]/java-17-for-absolute-beginners /");和日志打印
- 返回文件或目录的绝对路径名。创建文件处理程序时,并不总是需要绝对路径名,但是如果您稍后需要在代码中使用它或者确保正确解析了相对路径,这种方法正是您所需要的。下面这段代码通过使用相对于根项目目录(在我们的例子中是
java-17-for-absolute-beginners目录)的路径来创建一个指向resources目录中的文件的文件处理程序。
INFO c.a.b.e.Main - Type : directory or symlink
File d = new File("chapter11/read-write-file/src/main/resources/input/");
getAbsolutePath()方法返回完整的路径名,它由 log 语句打印出来,如下所示:
INFO c.a.b.e.Main - Location :/[workspace]/java-17-for-absolute-beginners/chapter11/read-write-file/src/main/resources/input
Java File类相当强大;它可用于指向另一台计算机上的共享文件。有一个特殊的构造函数用于接收类型为java.net.URI的参数,其中 URI 代表统一资源标识符。要测试这个构造函数,只需在您的计算机上选择一个文件,并在 web 浏览器中打开它,这样您就可以从浏览器地址栏中获得它的 URI。清单 11-2 中的代码描述了使用本地 URI 实例化的File类。
package com.apress.bgn.eleven.io;
import java.net.URI;
import java.net.URISyntaxException;
// other imports omitted
public class Main {
public static void main(String... args) {
try{
// replace [workspace] with your workspace path
var localUri = new URI("file:///[workspace]/java-17-for-absolute-beginners/README.adoc");
var localFile = new File (localUri);
printFileStats(localFile);
} catch (URISyntaxException use) {
log.error("Malformed URI, no file there", use);
}
}
}
Listing 11-2Printing File Details for a File Instance Create Using an URI
因为 URI 可能有一个不正确的前缀或者没有准确地指向一个文件,URI 构造函数被声明抛出一个java.net.URISyntaxException,所以在代码中你也必须处理这个问题。在使用 URI 创建文件处理程序的情况下,getAbsolutePath()方法返回文件在计算机和文件所在的驱动器上的绝对路径名。
-
getParent()返回包含文件的目录的绝对路径,因为在层次结构上,一个文件不能有另一个文件作为父文件。 -
getName()返回文件名。文件名包含扩展名作为调用"."后的后缀,用于指示文件的类型和用途。 -
length()返回文件的长度,以字节为单位。此方法不适用于目录,因为目录可能包含仅限于执行程序的用户使用的文件,并且可能会引发异常。所以如果你需要一个目录的大小,你必须自己写代码。 -
isHidden()返回true是文件对当前用户不可见,否则返回false。在 macOs/ Linux 系统上,文件名以"."开头的文件是隐藏的,所以如果我们想看到那个方法返回true,我们必须创建一个系统配置文件的处理程序,比如.gitconfig。因此,在使用隐藏文件的路径名创建的文件处理程序上调用printFileStats(..)会产生类似于清单 11-3 中的输出: -
canRead()和canWrite()是显而易见的,因为普通用户可以保护文件。当用户对文件拥有特定权限时,这两种方法都返回 true,否则返回 false。
INFO c.a.b.e.Main - File Details:
INFO c.a.b.e.Main - Type : file
INFO c.a.b.e.Main - Location :/Users/[userDir]/.gitconfig
INFO c.a.b.e.Main - Parent :/Users/[userDir]
INFO c.a.b.e.Main - Name : .gitconfig
INFO c.a.b.e.Main - Size : 3.865234375
INFO c.a.b.e.Main - Is Hidden : true
INFO c.a.b.e.Main - Is Readable? : true
INFO c.a.b.e.Main - Is Writable? : true
Listing 11-3Printing File Details for a Hidden File
可以为指向目录的路径名创建文件处理程序,这意味着可以调用特定于目录的方法。对目录最常见的操作是列出它的内容。list()方法返回一个String数组,包含这个目录下的文件(和目录)的名称。Lambda 表达式使得打印目录中的项目变得非常实用。
var d = new File("/[workspace]/java-17-for-absolute-beginners");
Arrays.stream(Objects.requireNonNull(d.list())).forEach(ff -> log.info("\t File Name : {}", ff));
文件名在大多数情况下并不真正有用;拥有一个带有文件处理程序的File数组会更好。这就是为什么在 1.2 版本中增加了listFiles()方法。
Arrays.stream(Objects.requireNonNull(d.listFiles())).forEach(ff → log.info("\t File : {}", ff.getAbsolutePath()));
这个方法有不止一种形式,因为当用一个FileFilter的实例调用时,它可以用来过滤文件并只返回符合特定要求的文件或目录的文件处理程序。清单 11-4 中的代码示例过滤目录下的条目,只保留名称以“章节”开头的目录。
package com.apress.bgn.eleven.io;
import java.io.File;
import java.io.FileFilter;
// other imports omitted
public class Main {
public static void main(String... args) {
// replace [workspace] with your workspace path
var d = new File("/[workspace]/java-17-for-absolute-beginners");
Arrays.stream(d.listFiles(new FileFilter() {
@Override
public boolean accept(File childFile) {
return childFile.isDirectory() && childFile.getName().startsWith("chapter");
}
})).forEach(ff -> log.info("Chapter Source : {}", ff.getName()));
}
}
Listing 11-4Filtering Content of a Directory Using a FileFilter Instance
前面的代码示例是以扩展的形式编写的,目的是为了清楚地表明您应该为accept(..)方法提供一个具体的实现。使用 lambda 表达式,可以简化前面的代码,甚至使其不容易抛出异常。
Arrays.stream(
Objects.requireNonNull(d.listFiles(
childFile -> childFile.isDirectory() && childFile.getName().startsWith("chapter")))
).forEach(ff -> log.info("Chapter Source : {}", ff.getName())
);
在前面的例子中,我们实现了accept(..)来根据文件类型和名称进行过滤,但是过滤器可以包含任何内容。当您需要的过滤器严格涉及文件名时,您可以减少使用该方法的另一个版本,它接收一个FilenameFilter实例作为参数。
Arrays.stream(Objects.requireNonNull(d.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return dir.getName().startsWith("chapter");
}
}))).forEach(ff -> log.info("\t File : {}", ff.getAbsolutePath()));
除了列出文件的属性,文件处理程序也可以用来创建文件。要创建一个文件,必须在创建一个具有特定路径名的文件处理程序后调用createNewFile()方法,如清单 11-5 所示。
package com.apress.bgn.eleven.io;
import java.io.IOException;
// other imports omitted
public class Main {
public static void main(String... args) {
var created = new File(
"chapter11/read-write-file/src/main/resources/output/created.txt");
if (!created.exists()) {
try {
created.createNewFile();
} catch (IOException e) {
log.error("Could not create file.", e);
}
}
}
}
Listing 11-5Creating a File
当文件句柄与一个具体的文件或目录相关联时,exists()方法返回true,否则返回false。它可以用来测试我们试图创建的文件是否已经存在。如果该文件存在,则该方法无效。如果用户没有适当的权限在指定的路径名创建文件,将抛出一个SecurityException。在某些情况下,我们可能需要创建一个只在程序执行期间使用的文件。这意味着我们要么创建文件并显式删除它,要么创建一个临时文件。通过调用createTempFile(prefix, suffix)创建临时文件,它们被创建在为操作系统定义的临时目录中。prefix 参数的类型为 String,创建的文件将以其值开始命名。后缀参数也是字符串类型,它可以用来指定文件的扩展名。文件名的其余部分由操作系统生成。清单 11-6 中描述了创建临时文件的代码。
package com.apress.bgn.eleven.io;
import java.io.IOException;
// other imports omitted
public class Main {
public static void main(String... args) {
try {
File temp = File.createTempFile("java_bgn_", ".tmp");
log.info("File created.txt at: {}", temp.getAbsolutePath());
temp.deleteOnExit();
} catch (IOException e) {
log.error("Could not create temporary file.", e);
}
}
}
Listing 11-6Creating a Temporary File
操作系统的临时目录中的文件会被操作系统定期删除,但是如果您想确保它会被删除,您可以在临时文件的文件处理程序上显式调用deleteOnExit()。在前面的代码示例中,打印了文件的绝对路径,以显示创建临时文件的确切位置,并且在 macOS 系统上,完整路径名看起来与此非常相似:
/var/folders/gg/nm_cb2lx72q1lz7xwwdh7tnc0000gn/T/java_bgn_14652264510049064218.tmp
也可以使用 Java 文件处理程序重命名文件,有一个名为rename(f)的方法,使用文件处理程序参数调用该方法,指向文件应该具有的位置和所需名称。如果重命名成功,该方法返回true,否则返回 false。清单 11-7 中描述了这样做的代码。
package com.apress.bgn.eleven.io;
import java.io.IOException;
// other imports omitted
public class Main {
public static void main(String... args) {
var file = new File(
"chapter11/read-write-file/src/main/resources/output/created.txt");
var renamed = new File(
"chapter11/read-write-file/src/main/resources/output/renamed.txt");
boolean result = file.renameTo(renamed);
log.info("Renaming succeeded? : {} ", result);
}
}
Listing 11-7Renaming a File
类File中的大多数方法都会抛出IOException,因为操作文件可能会因为硬件问题或操作系统问题而失败。这种类型的异常是检查异常,使用文件处理程序的开发人员被迫捕捉和处理这种类型的异常。
需要特殊权限才能访问文件的方法抛出SecurityException。这个类型扩展了RuntimeException,所以不检查异常。当应用运行时,它们变得很明显。
既然已经介绍了使用文件处理程序的所有基础,现在是下一节的时候了。
路径处理程序
Java 1.7 中引入了java.nio.file.Path接口以及实用程序类java.nio.file.Files和java.nio.file.Paths,以提供新的、更实用的方式来处理文件。Path实例可用于定位文件系统中的文件,因此代表系统相关的文件路径。Path实例比File更实用,因为它们可以提供访问路径组件、组合路径和比较路径的方法。Path不能直接创建实例,因为接口不能被实例化,但是接口提供了静态实用方法来创建它们,类Paths也是如此。根据你的情况使用你想要的。
创建一个Path实例最简单的方法是从一个文件处理程序开始并调用Paths.get(fileURI),如清单 11-8 所示。
package com.apress.bgn.eleven.io;
// other imports omitted
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
public class PathDemo {
private static final Logger log = LoggerFactory.getLogger(PathDemo.class);
public static void main(String... args) {
// replace [workspace] with your workspace path
File file = new File(
"/[workspace]/java-17-for-absolute-beginners/README.adoc");
Path path = Paths.get(file.toURI());
log.info(path.toString());
}
}
Listing 11-8Creating a Path Instance
从 Java 11 开始,Paths.get(file.toURI())可以替换为Path.of(file.toURI())。创建Path实例的另一种方法是使用另一种形式的Paths.get(..),,它接收多段路径作为参数。
Path composedPath = Paths.get("/[workspace]",
"java-17-for-absolute-beginners",
"README.adoc");
log.info(composedPath.toString());
之前创建的两条路径指向同一个位置,因此如果使用compareTo(..)方法相互比较(因为Path扩展了接口Comparable<Path>,返回的结果将是 0(零),这意味着路径相等。
log.info("Is the same path? : {} ", path.compareTo(composedPath) ==0 ? "yes" : "no");
// prints : INFO com.apress.bgn.eleven.PathDemo - Is the same path? : yes
在下一个代码示例中,在 path 实例上调用了一些Path方法。清单 11-9 中描述了代码。
package com.apress.bgn.eleven.io;
// import section omitted
public class PathDemo {
private static final Logger log = LoggerFactory.getLogger(PathDemo.class);
public static void main(String... args) {
var path = Paths.get("/[workspace]",
"java-17-for-absolute-beginners",
"README.adoc");
printPathDetails(path);
}
private static void printPathDetails(Path path) {
log.info("Location :{}", path.toAbsolutePath());
log.info("Is Absolute? : {}", path.isAbsolute());
log.info("Parent :{}", path.getParent());
log.info("Root :{}", path.getRoot());
log.info("FileName : {}", path.getFileName());
log.info("FileSystem : {}", path.getFileSystem());
log.info("IsFileReadOnly : {}", path.getFileSystem().isReadOnly());
}
}
Listing 11-9Inspecting Path Details
以下列表解释了每种方法及其结果:
-
toAbsolutePath()返回表示该路径绝对路径的 Path 实例。当在先前创建的 path 实例上调用时,由于它已经是绝对的,该方法将只返回调用该方法的 path 对象。同样,调用path.isAbsolute()将返回true。 -
getParent()返回父Path实例。在 path 实例上调用此方法将打印: -
INFO com.apress.bgn.eleven.PathDemo - Parent :/[workspace]/java-17-for-absolute-beginners -
getRoot()返回该路径的根组件作为一个Path实例。在 Linux 或 macOS 系统上打印"/",在 Windows 上类似于"C:\"。 -
getFileName()返回由该路径表示的文件或目录的名称作为Path实例;基本上,路径被系统路径分隔符拆分,离根元素最远的返回。 -
getFileSystem()返回创建该对象的文件系统,对于 macOS,它是类型sun.nio.fs.MacOSXFileSystem的实例。
另一个有用的Path方法是resolve(..)。这个方法采用一个代表路径的String实例,并根据它被调用的路径实例来解析它。这意味着添加了路径分隔符来根据程序运行的操作系统组合两个路径,并将返回一个Path实例。这在清单 11-10 中有所描述。
package com.apress.bgn.eleven.io;
// import section omitted
public class PathDemo {
private static final Logger log = LoggerFactory.getLogger(PathDemo.class);
public static void main(String... args) {
// replace [workspace] with your workspace path
var chapterPath = Paths.get("/[workspace]",
"java-17-for-absolute-beginners/chapter11");
Path filePath = chapterPath.resolve(
"read-write-file/src/main/resources/input/data.txt");
log.info("Resolved Path :{}", filePath.toAbsolutePath());
}
}
Listing 11-10Resolving a Path Instance
前面的示例代码将打印以下内容:
INFO c.a.b.e.PathDemo - Resolved Path :/[workspace]/java-17-for-absolute-beginners/chapter11/read-write-file/src/main/resources/input/data.txt
使用Path实例,结合Files实用程序方法,编写管理文件或检索其属性的代码变得更加容易。清单 11-11 中的代码示例使用了其中的一些方法来打印文件的属性,就像我们之前使用File处理程序一样。
package com.apress.bgn.eleven.io;
// import section omitted
public class PathDemo {
private static final Logger log = LoggerFactory.getLogger(PathDemo.class);
public static void main(String... args) {
try {
var outputPath = FileSystems.getDefault()
.getPath("/[workspace]" +
"java-17-for-absolute-beginners/chapter11/read-write-file/src/main/resources/output/sample");
Path dirPath = Files.createDirectory(outputPath);
printPathStats(dirPath);
} catch (FileAlreadyExistsException faee) {
log.error("Directory already exists.", faee);
} catch (IOException e) {
log.error("Could not create directory.", e);
}
}
private static void printPathStats(Path path) {
if (Files.exists(path)) {
log.info("Path Details:");
log.info("Type: {}", Files.isDirectory(path) ? "yes" : "no");
log.info("Type: {}", Files.isRegularFile(path) ? "yes" : "no");
log.info("Type: {}", Files.isSymbolicLink(path) ? "yes" : "no");
log.info("Location :{}", path.toAbsolutePath());
log.info("Parent :{}", path.getParent());
log.info("Name : {}", path.getFileName());
try {
double kilobytes = Files.size(path) / (double)1024;
log.info("Size : {} ", kilobytes);
log.info("Is Hidden: {}", Files.isHidden(path) ? "yes" : "no");
} catch (IOException e) {
log.error("Could not access file.", e);
}
log.info("Is Readable: {}", Files.isReadable(path) ? "yes" : "no");
log.info("Is Writable: {}", Files.isWritable(path) ? "yes" : "no");
}
}
}
Listing 11-11Printing a Path Details
如您所见,Files类提供了与File类相同的功能。这个类只包含对文件、目录或其他类型的文件进行操作的静态方法。它是在 Java 1.7 中引入的,其优点是语法更清晰。在管理文件、创建文件、重命名文件、删除文件以及读写文件时,使用java.nio类的功能和实用性更加明显。清单 11-12 中的代码示例展示了使用 NIO 类创建、重命名和删除文件。
package com.apress.bgn.eleven.io;
// import section omitted
import java.nio.FileAlreadyExistsException;
public class PathDemo {
private static final Logger log = LoggerFactory.getLogger(PathDemo.class);
public static void main(String... args) {
Path filePath = chapterPath.resolve(
"read-write-file/src/main/resources/input/data.txt");
Path copyFilePath = Paths.get(outputPath.toAbsolutePath().toString(), "data.adoc");
try {
Files.copy(filePath, copyFilePath);
log.info("Exists? : {}", Files.exists(copyFilePath)? "yes": "no");
log.info("File copied to: {}", copyFilePath.toAbsolutePath());
} catch (FileAlreadyExistsException faee) {
log.error("File already exists.", faee);
} catch (IOException e) {
log.error("Could not copy file.", e);
}
Path movedFilePath = Paths.get(outputPath.toAbsolutePath().toString(), "copy-data.adoc");
try {
Files.move(copyFilePath, movedFilePath);
log.info("File moved to: {}", movedFilePath.toAbsolutePath());
Files.deleteIfExists(copyFilePath);
} catch (FileAlreadyExistsException faee) {
log.error("File already exists.", faee);
} catch (IOException e) {
log.error("Could not move file.", e);
}
}
}
Listing 11-12Managing Files Using NIO Classes
请注意FileAlreadyExistsException,这是 Java 1.7 中添加的一个异常类型,它扩展了IOException(间接通过FileSystemException),用于提供更多关于文件操作失败的情况的数据。通过createDirectory(..)、createFile(..),和move(..).方法得出
如果要删除的文件不存在,前面的代码示例中没有使用的The delete(..)方法会抛出一个java.nio.file.NoSuchFileException。为了避免抛出异常,在前面的代码示例中使用了deleteIfExists(..)。
方法的列表甚至更大,但是由于本章的篇幅有限,您可以在官方的 Javadoc API 中亲自查看。
读取文件
文件是硬盘上一连串的位。一个File处理程序不提供读取文件内容的方法,但是一组其他类可以用来这样做,但是它们都是使用文件处理程序实例创建的。根据对文件内容的实际需要,在 Java 中有多种读取文件内容的方法。有很多方法,本节将介绍最常见的方法。
使用Scanner读取文件
之前使用了Scanner类从命令行读取输入。System.in可以替换为File,可以使用Scanner方法读取文件内容,如清单 11-13 所示。
package com.apress.bgn.eleven.io;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Scanner;
public class ScannerDemo {
private static final Logger log = LoggerFactory.getLogger(ScannerDemo.class);
public static void main(String... args) {
try {
var scanner = new Scanner(new File("chapter11/read-write-file/src/main/resources/input/data.txt"));
var content = "";
while (scanner.hasNextLine()) {
content += scanner.nextLine() + "\n";
}
scanner.close();
log.info("Read with Scanner --> {}", content);
} catch (IOException e) {
log.error("Something went wrong! ", e);
}
}
}
Listing 11-13Using Scanner to Read a File
也可以使用一个java.nio.file.Path实例来代替文件:
scanner = new Scanner(Paths.get(new File("chapter11/read-write-file/src/main/resources/input/data.txt").toURI()), StandardCharsets.UTF_8.name());
文件可以使用不同的字符集编写,在 Java 中由java.nio.charset.Charset实例引用。为了确保它们被正确读取,使用相同的字符集读取它们是一个很好的做法。有一个扫描器构造函数,它接收一个字符集名称作为参数。调用StandardCharsets.UTF_8.name()方法来提取 UTF-8 字符集的名称。
使用Files实用程序方法读取文件
清单 11-14 中的第一个代码示例展示了读取文件的最简单方法。
package com.apress.bgn.eleven.io;
// import section omitted
public class FilesReadDemo {
private static final Logger log = LoggerFactory.getLogger(FilesReadDemo.class);
public static void main(String... args) {
try {
var file= new File("chapter11/read-write-file/src/main/resources/input/data.txt");
var content = new String(Files.readAllBytes(Paths.get(file.toURI())));
log.info("Read with Files.readAllBytes --> {}", content);
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-14The Simplest Way to Read a File
当文件大小可以近似时(文件大小可以估计,并且相对较小),这种方法工作得很好,并且将其存储到一个String对象中不会有问题。
使用Files.readAllBytes(..)的优点是不需要循环,我们不必一行一行地构造String值,因为这个方法只是读取文件中所有可以作为参数给String构造函数的字节。缺点是没有使用Charset,所以文本值可能不是我们所期望的。有一种方法可以克服这个问题,通过调用将文件内容作为一列String值返回的Files.readAllLines(..),并有两个表单,其中一个将Charset声明为参数。清单 11-15 中描述了读取文件的这个版本。
package com.apress.bgn.eleven.io;
// import section omitted
public class FilesReadDemo {
private static final Logger log = LoggerFactory.getLogger(FilesReadDemo.class);
public static void main(String... args) {
try {
var file= new File("chapter11/read-write-file/src/main/resources/input/data.txt");
List<String> lyricList = Files.readAllLines(Paths.get(file.toURI()), StandardCharsets.UTF_8);
lyricList.forEach(System.out::println);
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-15A Simple Way to Read a File Specifying a Charset
但是如果我们不需要一个List<String>,而是需要一个String实例呢?在 Java 11 中为此引入了一个方法,叫做readString(..)。清单 11-16 中显示了使用它的代码示例。
package com.apress.bgn.eleven.io;
// import section omitted
public class FilesReadDemo {
private static final Logger log = LoggerFactory.getLogger(FilesReadDemo.class);
public static void main(String... args) {
try {
var content = Files.readString(Paths.get(file.toURI()), StandardCharsets.UTF_8);
log.info("Read with Files.readString --> {}", content);
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-16The Simplest Way to Read a File Specifying a Charset
使用Readers读取文件
在引入Files类和它的奇特方法之前,有其他读取文件的方法。奇特的方法也不是为读取大文件或只读取文件的一部分而设计的。让我们回到过去,慢慢分析事情是如何演变的。
在 Java 1.6 之前,要逐行读取文件,您必须编写一个类似清单 11-17 中的装置。
package com.apress.bgn.eleven.io;
import java.io.BufferedReader;
import java.io.FileReader;
// other imports omitted
public class ReadersDemo {
private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);
public static void main(String... args) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(new File("chapter11/read-write-file/src/main/resources/input/data.txt")));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
log.info("Read with BufferedReader --> {}", sb.toString());
} catch (Exception e) {
log.error("File could not be read! ", e);
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException ioe) {
log.error("Something went wrong! ", ioe);
}
}
}
}
}
Listing 11-17Reading a File Line By Line, Before Java 1.6
哇,那是什么,对吗?在 Java 1.6 之后,语法有所简化,但是最大的变化出现在 1.7。在 Java 1.7 之前,如果您想逐行读取文件,您必须编写以下代码:
-
您必须创建一个
File处理程序。 -
然后,您需要将文件处理程序包装到一个
FileReader中。这种类型的实例可以完成读取的工作,但是只能读取大块的char[],当您需要实际的文本时,这不是很有用。 -
需要将
FileReader实例包装到BufferedReader实例中,通过读取内部缓冲区中的字符来提供该功能。它的工作方式是,当这个方法返回null时,调用reader.readLine()直到因为到达了文件的结尾而没有更多要读取的内容。 -
在读取结束时,需要显式调用
reader.close(),否则文件可能会被锁定,直到重新启动后才可读。
在 Java 1.7 中,引入了许多变化来减少处理文件所需的样板文件。其中之一是,所有用于访问文件内容和可以保持文件锁定的类都通过声明实现java.io.Closeable接口来丰富,该接口将这些类型的资源标记为可关闭的,并且在执行结束之前,JVM 会调用一个close()方法来透明地释放资源。同样,在 Java 7 中,引入了try-with-resources语句。利用所有这些特性,前面的代码可以如清单 11-18 所示编写。
package com.apress.bgn.eleven.io;
// other imports omitted
public class ReadersDemo {
private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);
public static void main(String... args) {
try (var br = new BufferedReader(new FileReader(new File("chapter11/read-write-file/src/main/resources/input/data.txt")))){
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
log.info("Read with BufferedReader --> {}", sb.toString() );
} catch (Exception e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-18Reading a File Line By Line, Starting with Java 1.7
代码可以进一步简化,因为FileReader可以将文件的绝对路径作为参数String。但是不能使代码考虑编码。这在 Java 1.8 中成为可能,当时为接受Charset参数的FileReader类引入了一个构造函数。尽管如此,在前面的例子中我们有嵌套的构造函数调用,这是相当难看的。通过引入Files.newBufferedReader(Path)和Files.newBufferedReader(Path, Charset)方法,Java 8 来拯救我们了。
所以前面的代码可以写成清单 11-19 所示。
package com.apress.bgn.eleven.io;
// other imports omitted
public class ReadersDemo {
private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);
public static void main(String... args) {
File file = new File("chapter11/read-write-file/src/main/resources/input/data.txt");
try (var br = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)){
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
log.info("Read with BufferedReader --> {}", sb.toString() );
} catch (Exception e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-19Reading a File Line By Line, Taking Encoding Into Consideration Starting with Java 1.8
如果已知文件的大小是可管理的,并且我们不感兴趣的只是记录内容,而是保存单独的行以供进一步处理,那么最简单的方法就是使用结合了 lambda 表达式的Files.readAllLines(..)方法。可以在混合中添加流,因此可以在现场过滤或处理这些线,如下所示:
List<String> dataList = Files.readAllLines(Paths.get(file.toURI()), StandardCharsets.UTF_8)
.stream()
.filter(line -> line!= null && !line.isBlank())
.map(line -> line.toUpperCase())
.collect(Collectors.toList());
或者我们可以用另一种方式编写,使用也是在 Java 1.8 中引入的Files.lines(..)方法,并直接以流的形式获取所有内容:
List<String> dataList = Files.lines(Paths.get(file.toURI()), StandardCharsets.UTF_8)
.filter(line -> line!= null && !line.isBlank() )
.map(line -> line.toUpperCase())
.collect(Collectors.toList());
总之,回到文件阅读器。如果是扩展了Reader类的类组的成员,则为BufferedReader类。Reader类是一个用于读取字符流的抽象类,是java.io包的一部分。图 11-1 描述了显示最常用实现的简化层次结构。
图 11-1
Reader类层次结构(如 IntelliJ IDEA 所示)
字符流可以有不同的来源,文件是最常见的。它们提供对存储在文件中的数据的顺序访问。BufferedReader不支持字符编码,但是BufferedReader基于另一个Reader实例。正如您在前面的例子中注意到的,在实例化一个BufferedReader时,一个FileReader实例被用作参数,并且在 Java 1.8 中FileReader被修改以支持字符编码。在 Java 1.8 之前,为了从文件中读取并考虑字符编码,使用了一个InputStreamReader实例,如清单 11-20 所示。
package com.apress.bgn.eleven.io;
import java.io.FileInputStream;
import java.io.InputStreamReader;
// other imports omitted
public class ReadersDemo {
private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);
public static void main(String... args) {
File file = new File("chapter11/read-write-file/src/main/resources/input/data.txt");
try (var br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))){
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append("\n");
}
log.info("Read with BufferedReader(InputStreamReader(FileInputStream(..))) --> {}", sb.toString() );
} catch (Exception e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-20Reading a File Line By Line, Taking Encoding Into Consideration Before Java 1.8
在 Java 11 中,Reader类用nullReader()方法进行了丰富,该方法返回一个不做任何事情的Reader实例。这是开发人员出于测试目的而要求的,只不过是一个伪阅读器实现。
使用InputStream读取文件
Reader系列中的类是将数据作为文本读取的高级类,但从技术上讲,文件只是一个字节序列,所以这些类本身是用于读取字节流的类系列中的类的包装器。当试图使用正确的字符编码时,以及当使用BufferedReader读取文本时(如前一节末尾所示),这变得非常明显,因为作为参数给出的InputStreamReader实例是基于java.io.FileInputStream实例的,而后者是java.io.InputStream的子类。
这个层次的根类是java.io.InputStream。图 11-2 描述了显示最常用实现的简化层次结构。
图 11-2
InputStream类层次结构(如 IntelliJ IDEA 所示)
类BufferedInputStream相当于用于读取字节流的BufferedReader。我们之前用来从控制台读取用户数据的System.in就是这种类型,Scanner实例将来自其缓冲区的字节转换成用户可理解的数据。当我们感兴趣的数据不是使用 Unicode 惯例存储的文本,而是原始数字数据(图像、媒体文件、pdf 等二进制文件)时。)使用字节流的类更合适。只是为了向您展示它是如何完成的,我们将使用FileInputStream来读取data.txt文件的内容。代码如清单 11-21 所示。
package com.apress.bgn.eleven.io;
import java.io.FileInputStream;
// other imports omitted
public class FileInputStreamReadingDemo {
private static final Logger log = LoggerFactory.getLogger(FileInputStreamReadingDemo.class);
public static void main(String... args) {
File file = new File("chapter11/read-write-file/src/main/resources/input/data.txt");
try {
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[1024];
StringBuilder sb = new StringBuilder();
while (fis.read(buffer) != -1) {
sb.append(new String(buffer));
buffer = new byte[1024];
}
fis.close();
log.info("Read with FileInputStream --> {}", sb.toString() );
} catch (IOException e) {
log.error("Something went wrong! ", e);
}
}
}
Listing 11-21Reading a File Using FileInputStream
如果您运行前面的代码,您会注意到在控制台中将会打印出预期的输出,但是您可能会注意到一些奇怪的事情:在打印出文本之后,还会打印出一组奇怪的字符。在 macOS 系统上,它们看起来如图 11-3 所示。
图 11-3
用FileInputStream阅读文本
你知道这些字符可能是什么吗?
没想法也没关系;我第一次使用FileInputStream读取文件时也没有。这些字符出现在那里是因为文件大小不是 1024 的倍数,所以FileInputReader最终用零填充最后一个缓冲区的剩余部分。解决这个问题的方法包括计算文件的字节大小,并确保我们相应地调整byte[] buffer的大小。如果你有心情写一些代码,你可以试着把它作为一个练习。既然我们已经向您展示了如何以多种方式读取文件,我们可以继续向您展示如何编写文件,因为您已经知道如何创建它们。
在 Java 11 中,InputStream还增加了一个方法,返回一个什么也不做的InputStream。它被命名为nullInputStream()方法,是为测试目的而设计的,只不过是一个伪InputStream实现.
到目前为止介绍的所有类都是您在 Java 中处理文件时最常遇到的。如果您需要更专业的读者,请随意阅读官方文档或使用第三方库(如 Apache Commons IO)提供的自定义实现。 2
写文件
用 Java 写文件和读文件非常相似,只是必须使用不同的类,因为流是单向的。用于读取数据的流也不能用于写入数据。几乎任何读取文件的类或方法都有一个用于写入文件的类或方法。事不宜迟,我们开始吧。
使用文件实用程序方法编写文件
从 Java 1.7 开始,使用Files.write(Path, byte[], OpenOption... options)方法可以很容易地编写较小的文件。它有两个参数:一个代表文件位置的Path和一个代表要写入的数据的字节数组。当需要写入的数据足够小时,这种方法是一个实用的一行程序。最后一个参数实际上是在第章 4 中引入的 Varargs ,它表示打开文件的一个或多个操作。如清单 11-22 所示,可以在不指定任何该类型参数的情况下使用该方法。
package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FilesWritingDemo {
private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);
public static void main(String... args) {
var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
byte[] data = "Some of us, we’re hardly ever here".getBytes();
try {
Path dataPath = Files.write(file.toPath(), data);
log.info("String written to {}", dataPath.toAbsolutePath());
} catch (IOException e) {
e.printStackTrace();
}
}
}
Listing 11-22Writing a String to a File Starting with Java 1.7
如果文件已经存在,内容将被简单地覆盖。这意味着,由于没有指定参数来配置我们想要对文件做什么,默认的行为是打开文件进行写入,将其大小截断为零,并从那里开始写入,从而覆盖它。可用选项列表由java.nio.file.StandardOpenOption枚举中的值建模。默认行为对应的值是TRUNCATE_EXISTING。所以上例中的这一行:
Path dataPath = Files.write(file.toPath(), data);
相当于
import java.nio.file.StandardOpenOption
...
Path dataPath = Files.write(file.toPath(), data, StandardOpenOption.TRUNCATE_EXISTING);
如果想要的行为是修改一个文件(如果它存在的话)并在末尾追加新数据,那么用作Files.write(..)方法的参数的选项是APPEND。Path dataPath = Files.write(file.toPath(), data, StandardOpenOption.APPEND);
此外,请注意字符串在写入之前需要如何转换为字节数组。在 Java 11 中这不再是必要的,因为最终一些 JDK 开发者认为大多数人可能会写一个简单的String到一个文件中,强迫他们显式地调用getBytes()是非常愚蠢的。结果是引入了Files.writeString(..)方法,其中一个还支持指定编码。在清单 11-23 中可以看到一个将字符串写入文件的方法的例子。
package com.apress.bgn.eleven.io;
// import statements omitted
public class FilesWritingDemo {
private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);
public static void main(String... args) {
var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
try {
Path dataPath = Files.writeString(file.toPath(),
"\nThe rest of us, we're born to disappear",
StandardCharsets.UTF_8,
APPEND);
log.info("String written to {}", dataPath.toAbsolutePath());
} catch (IOException e) {
e.printStackTrace();
}
}
}
Listing 11-23Writing a String to a File Starting with Java 11
另一个版本的Files.write(..)接受一个类型为Iterable<? extends CharSequence>的参数,这意味着可以用它来编写一个String值的列表,如清单 11-24 所示。
package com.apress.bgn.eleven.io;
// import statements omitted
public class FilesWritingDemo {
private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);
public static void main(String... args) {
var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
List<String> dataList = List.of(
"How do I stop myself from",
"Being just a number?");
try {
Path dataPath = Files.write(file.toPath(), dataList,
StandardCharsets.UTF_8,
APPEND);
log.info("String written to {}", dataPath.toAbsolutePath());
} catch (IOException e) {
e.printStackTrace();
}
}
}
Listing 11-24Writing a List<String> to a File Using Files.write(..)
接下来,我们将研究如何使用Writer层次结构中的类来编写文件。
使用Writer写文件
类似于读取文件的Reader层次结构,有一个名为Writer的抽象类,但是在我们开始之前,让我们先介绍一下BufferedWriter,它是BufferedReader的通讯器,用于写文件,因为这是实践中使用最多的一个。这个类也有一个内部缓冲区,当调用 write 方法时,参数被存储到缓冲区,当缓冲区满了,它的内容被写入文件。通过调用flush()方法可以提前清空缓冲区。绝对建议在调用close()之前显式调用这个方法,以确保所有输出都被写入文件。清单 11-25 中的代码片段描述了如何将String实例列表写入文件。
package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.BufferedWriter;
import java.io.FileWriter;
public class FilesWritingDemo {
private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);
public static void main(String... args) {
var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
var dataList = List.of ("How will I hold my head" ,
"To keep from going under");
BufferedWriter writer = null;
try {
writer = new BufferedWriter(new FileWriter(file));
for (String entry : dataList) {
writer.write(entry);
writer.newLine();
}
} catch (IOException e) {
log.info("Something went wrong! ", e);
} finally {
if(writer!= null) {
try {
writer.flush();
writer.close();
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
}
}
}
}
Listing 11-25Writing a List<String> to a File \Using BufferedWriter
还需要另一个代码装置,因为写文件是一个敏感的操作,可能会因为许多原因而失败。前面清单中的代码是您在 Java 1.7 之前必须编写的,当时try-with-resources减少了样板文件,并允许减少前面的代码,如清单 11-26 所示。
package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.BufferedWriter;
import java.io.FileWriter;
public class FilesWritingDemo {
private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);
public static void main(String... args) {
var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
var dataList = List.of ("How will I hold my head" ,
"To keep from going under");
try (final BufferedWriter wr = new BufferedWriter(new FileWriter(file))){
dataList.forEach(entry -> {
try {
wr.write(entry);
wr.newLine();
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
});
wr.flush();
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-26Writing a List<String> to a File Using BufferedWriter
注意为什么不需要调用wr.close(),因为在 Java 1.7 中java.io.Closeable接口被修改为扩展java.lang.AutoCloseable,它声明了在退出try-with-resources块时自动调用的close()方法的一个版本。尽管如此,代码看起来相当乏味,对不对?尤其是因为需要声明一个BufferedWriter并需要包装一个FileWriter实例。这在 Java 1.8 中得到了简化,增加了Files实用程序类,它包含一个名为newBufferedWriter(Path path)的方法,该方法返回一个BufferedWriter实例,因此开发人员不再需要显式地编写代码。因此清单 11-26 中try-with-resources的初始化表达式可以替换为:
final BufferedWriter wr = Files.newBufferedWriter(file.toPath())
此外,该方法还有一个版本采用 charset 参数:
final BufferedWriter wr = Files.newBufferedWriter(file.toPath(),StandardCharsets.UTF_8)
在引入这种方法之前,用指定的字符集将文本写入文件需要一个java.io.OutputStreamWriter实例。
final OutputStreamWriter wr = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)
这个方法还有一个版本,它采用类型为OpenOption的参数,允许您指定应该如何打开文件。
final BufferedWriter wr = Files.newBufferedWriter(file.toPath(),StandardCharsets.UTF_8, StandardOpenOption.APPEND)
这非常有用,因为显式创建的BufferedWriter(没有指定文件选项)会覆盖现有文件,除非将回绕的FileWriter配置为将数据追加到现有文件,如下所示:
final BufferedWriter wr = new BufferedWriter(new FileWriter(file, true))
第二个参数是一个布尔值,表示是否应该打开文件以追加文本(true)或不追加文本(false)。
既然已经介绍了使用BufferedWriter的基本知识,现在该见见图 11-4 中描绘的Writer家族中最有用的成员了。
图 11-4
Writer阶级阶层
Writer类是抽象的,所以不能直接使用;附加的 API 来自于Writer实现的java.io.Appendable接口。其他Writer类用于不同的目的。正如我们已经看到的,OutputStreamWriter是用来用一种特殊的字符编码写文本的。
PrintWriter用于将对象的格式化表示写入文本输出流(在前一章中,我们已经用它编写了 HTML 代码)。
StringWriter用于将输出收集到其内部缓冲区中,并将其写入一个String实例。
在 Java 11 中,Writer类用nullWriter()方法进行了丰富,该方法返回一个不做任何事情的Writer实例。这是开发人员出于测试目的而要求的。
使用OutputStream写文件
Writer系列中的类是使用字符流将数据作为文本写入的高级类,但本质上,在数据被写入之前,它被转换成字节。这显然意味着也可以使用字节流来编写文件。当试图在使用OutputStreamWriter编写文本时使用正确的字符编码时,这可能变得很明显,因为作为参数给出的OutputStreamWriter实例是基于FileOutputStream实例的,这是一种用于将字节流写入文件的类型。
这个层次的根类是java.io.OutputStream,层次中最常见的成员如图 11-5 所示。
图 11-5
OutputStream阶级阶层
既然已经提到了FileOutputStream,清单 11-27 展示了如何使用它来编写一个String条目的列表。
package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
public class OutputStreamWritingDemo {
private static final Logger log = LoggerFactory.getLogger(OutputStreamWritingDemo.class);
public static void main(String... args) {
var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
var dataList = List.of("Down to the wire" ,
"I wanted water but" ,
"I'll walk through the fire" ,
"If this is what it takes");
try (FileOutputStream output = new FileOutputStream(file)){
dataList.forEach(entry -> {
try {
output.write(entry.getBytes());
output.write("\n".getBytes());
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
});
output.flush();
} catch (FileNotFoundException e) {
log.info("Something went wrong! ", e);
} catch (IOException e) {
e.printStackTrace();
}
}
}
Listing 11-27Writing a List<String> to a File Using FileOutputStream
OutputStream family 类用于写入代表用户无法直接读取的原始数据的字节流,例如包含在图像、媒体、pdf 等二进制文件中的字节流。例如,清单 11-28 中的代码使用FileInputStream读取图像并使用FileOutputStream写入副本来制作图像的副本。
package com.apress.bgn.eleven.io;
// other import statements missing
import java.io.*;
public class DuplicateImageDemo {
private static final Logger log = LoggerFactory.getLogger(DuplicateImageDemo.class);
public static void main(String... args) {
File src = new File(
"chapter11/read-write-file/src/main/resources/input/the-beach.jpg");
File dest = new File(
"chapter11/read-write-file/src/main/resources/output/copy-the-beach.jpg");
try(FileInputStream fis = new FileInputStream(src);
FileOutputStream fos = new FileOutputStream(dest)) {
int content;
while ((content = fis.read()) != -1) {
fos.write(content);
}
} catch (FileNotFoundException e) {
log.error("Something bad happened.", e);
} catch (IOException e) {
log.error("Something bad happened.", e);
}
}
}
Listing 11-28Making a Copy of an Image File Using FileOutputStream
然而,由于 Java 1.7 中引入了Files.copy(src.toPath(), dest.toPath())方法,因此不再需要像这样编写代码。
在 Java 11 中,OutputStream增加了 nullOutputStream()方法,该方法返回一个不做任何事情的OutputStream实例。这是开发人员出于测试目的而要求的,也是为测试目的而设计的,只不过是一个伪输出流实现。
使用 NIO 管理文件
本章开头介绍了java.nio包与java.io包的对比。本书这一节用到的大多数类和方法都是java.io包的一部分,当数据被读写时会阻塞主线程。上一节介绍的实用程序类java.nio.file.Paths和java.nio.file.Files包含了利用java.nio包和java.io包中的类的方法。是时候向您展示如何使用java.nio类来操作文件了。
使用java.nio操作文件需要一个java.nio.channels.FileChannel的实例。这是一个特殊的抽象类,描述了读取、写入、映射和操作文件的通道。一个FileChannel实例连接到一个文件,并在文件中保存一个可以被查询和修改的位置。
要使用FileChannel实例从文件中读取数据,需要以下内容:
-
文件处理程序实例
-
通道基于的
FileInputStream实例 -
一个实例
-
一个实例
由于是非阻塞的,线程可以请求通道从缓冲区读取数据,然后执行其他操作,直到数据可用。Java NIO 的缓冲区允许根据需要在缓冲区中来回移动。数据被读入缓冲区并缓存在那里,直到被处理。在java.nio包中有所有原语类型的缓冲实现,根据数据的用途,你可以使用其中的任何一个。清单 11-29 展示了如何将数据从一个文件读入一个ByteBuffer。由于可以用初始大小实例化ByteBuffer,通过将ByteBuffer的字节数配置为与文件大小相同,可以一次性读取文件。
package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class ChannelDemo {
private static final Logger log = LoggerFactory.getLogger(ChannelDemo.class);
public static void main(String... args) {
var sb = new StringBuilder();
try (FileInputStream is = new FileInputStream("chapter11/read-write-file/src/main/resources/input/data.txt");
FileChannel inChannel = is.getChannel()) {
long fileSize = inChannel.size();
ByteBuffer buffer = ByteBuffer.allocate((int)fileSize);
inChannel.read(buffer);
buffer.flip();
while(buffer.hasRemaining()){
sb.append((char) buffer.get());
}
} catch (IOException e) {
log.error("File could not be read! ", e);
}
log.info("Read with FileChannel --> {}", sb.toString());
}
}
Listing 11-29Reading a file Using FileChannel Using a ByteBuffer
方法getChannel()返回与这个文件输入流相关联的唯一的FileChannel对象。前面代码示例中最重要的语句是buffer.flip()调用。调用这个方法翻转缓冲区,意味着缓冲区从写模式切换到读模式。这意味着最初通道能够在缓冲区中写入数据,因为它处于写入模式,但是在缓冲区满了之后,缓冲区切换到读取模式,因此主线程可以读取其内容。
在读取一个缓冲区的内容后,如果需要再做一次,buffer.rewind()方法将位置设置为零。
如果文件很大,可以多次重新初始化ByteBuffer,但在这种情况下,必须在通道写入新数据之前清空缓冲区,这可以通过调用buffer.close()来完成。此外,使用FileInputStream来获取通道不是正确的方法,因为它限制了从文件中读取。但是通道可以读写文件,所以推荐的方法是使用一个java.io.RandomAccessFile实例作为文件处理程序,如清单 11-30 所示。
package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class ChannelDemo {
private static final Logger log = LoggerFactory.getLogger(ChannelDemo.class);
public static void main(String... args) {
var sb = new StringBuilder();
sb = new StringBuilder();
try (RandomAccessFile file = new RandomAccessFile("chapter11/read-write-file/src/main/resources/input/data.txt", "r");
FileChannel inChannel = file.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(48);
while(inChannel.read(buffer) > 0) {
buffer.flip();
for (int i = 0; i < buffer.limit(); i++) {
sb.append((char) buffer.get());
}
buffer.clear();
}
} catch (IOException e) {
log.error("File could not be read! ", e);
}
log.info("Read with FileChannel --> {}", sb.toString());
}
}
Listing 11-30Reading a File Using FileChannel Using a Smaller ByteBuffer
制作文件的副本也很简单;它只是使用缓冲区将数据从一个通道移动到另一个通道,如清单 11-31 所示。
package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class DuplicateImageDemo {
private static final Logger log = LoggerFactory.getLogger(DuplicateImageDemo.class);
public static void main(String... args){
final String inDir = "chapter11/read-write-file/src/main/resources/input/";
final String outDir = "chapter11/read-write-file/src/main/resources/output/";
try(FileChannel source =
new RandomAccessFile(inDir + "the-beach.jpg", "r").getChannel();
FileChannel dest =
new RandomAccessFile(outDir + "copy-the-beach.jpg", "rw").getChannel()) {
ByteBuffer buffer = ByteBuffer.allocateDirect(48);
while (source.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
dest.write(buffer);
}
buffer.clear();
}
} catch (Exception e) {
log.error("Image could not be copied! ", e);
}
}
}
Listing 11-31Duplicating an Image Using FileChannel and a ByteBuffer
另一种方法是使用专用的ReadableByteChannel和WritableByteChannel,如清单 11-32 所示。
package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
public class DuplicateImageDemo {
private static final Logger log = LoggerFactory.getLogger(DuplicateImageDemo.class);
public static void main(String... args){
final String inDir = "chapter11/read-write-file/src/main/resources/input/";
final String outDir = "chapter11/read-write-file/src/main/resources/output/";
try(ReadableByteChannel source = new FileInputStream (inDir + "the-beach.jpg").getChannel();
WritableByteChannel dest = new FileOutputStream (outDir + "2nd-copy-the-beach.jpg").getChannel()) {
ByteBuffer buffer = ByteBuffer.allocateDirect(48);
while (source.read(buffer) != -1) {
buffer.flip();
while (buffer.hasRemaining()) {
dest.write(buffer);
}
buffer.clear();
}
} catch (Exception e) {
log.error("Image could not be copied! ", e);
}
}
}
Listing 11-32Duplicating an Image Using ReadableByteChannel
and a ByteBuffer
由于它们的非阻塞特性,Java 通道适合于处理由多个数据源提供的数据的应用。这种用户应用通过网络管理与多个源的连接。图 11-6 描绘了Channel层级中最重要的成员。
图 11-6
channel类/接口层次结构(如 IntelliJ IDEA 所示)
DatagramChannel可以通过 UDP 在网络上读写数据。SocketChannel可以通过 TCP 在网络上读写数据,ServerSocketChannel 允许您像 web 服务器一样监听传入的 TCP 连接。为每个传入连接创建一个 SocketChannel。
引入 NIO 组件(接口和类)是为了补充现有的 IO 功能。Java IO 一次读取或写入一个字节或字符。缓冲利用 Java 堆内存,当使用相当大的文件时,这可能会成为问题。当 NIO 发布时,有一种说法是 NI0 比纯 Java I/O 更高效,性能更好,但这完全取决于您试图构建的应用。NIO 引入了批量处理原始字节的可能性、异步操作的可能性以及堆外缓冲。缓冲区是在 JVM 的中央内存之外创建的,位于不由垃圾收集器处理的内存部分。这允许创建更大的缓冲区,因此可以读取更大的文件,而没有因为 JVM 内存不足而抛出OutOfMemoryException的危险。
如果您发现自己需要处理大量的数据,请务必仔细阅读 JDK NIO 文档,因为这一节只是触及了皮毛。
序列化和反序列化
序列化是将对象的状态转换为字节序列的操作的名称。在这种格式中,它可以通过网络发送或写入文件,然后还原成该对象的副本。将字节序列转换回对象的操作被称为反序列化。Java 序列化一直是一个有争议的话题,Java 平台首席架构师 Mark Reinhold 将其描述为 1997 年犯下的一个可怕的错误。显然,大多数 Java 漏洞都与 Java 中序列化的方式有关,有一个名为 Amber 3 的项目致力于完全移除 Java 序列化,并允许开发人员以他们选择的格式选择序列化。
目前,JAVA 的情况很不稳定;在短时间内引入了相当多的变化,这是一个沉迷于向后兼容的行业无法适应的。下一节中的源代码可能不稳定,但我会尽最大努力让它们在书出版时至少是可编译的,我会维护资源库并尽可能多地回答问题。
字节序列化
java.io.Serializable接口没有方法或字段,只用于将类标记为可序列化。当对象被序列化时,标识对象类型的信息也被序列化。大多数 Java 类都是可序列化的。默认情况下,可序列化类的任何子类都被认为是可序列化的。如果任何字段不可序列化,那么将抛出类型为NotSerializableException的异常。开发人员编写的包含不可序列化字段的类必须实现Serializable接口,并为清单 11-33 中所示的方法提供具体的实现。
private void writeObject(java.io.ObjectOutputStream out)
throws IOException;
private void readObject(java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException;
private void readObjectNoData()
throws ObjectStreamException;
Listing 11-33Methods That Need to Be Emplemented to Make a Custom Class Serializable
这些方法不是特定 Java 接口的一部分,所以在这个上下文中实现它们只是意味着在您希望使之可序列化的类中为它们编写一个主体。在前面的清单中对它们进行分组的原因是为了描述这些方法的特征。
writeObject(..)方法用于写入对象的状态,以便readObject(..)方法可以恢复它。readObjectNoData()方法用于在反序列化操作由于某种原因失败时初始化对象的状态,因此尽管存在问题(例如,不完整的流、客户端应用无法识别反序列化的类等),该方法仍会提供默认状态。).如果你是一个乐观主义者,这个方法并不是必须的。
此外,当使类可序列化时,必须添加 long 类型的静态字段作为该类的唯一标识符,以确保以字节流形式发送对象的应用和接收该对象的客户端应用具有相同的加载类。如果接收字节流的应用有一个不同标识符的类,将抛出一个java.io.InvalidClassException。当这种情况发生时,这意味着应用没有更新,或者您甚至可能怀疑黑客的一些不法行为。该字段必须命名为serialVersionUID,如果开发人员没有显式添加,序列化运行时将会添加。清单 11-34 中的以下代码片段描述了一个名为Singer的类,它包含前面代码片段中提到的序列化和反序列化方法。
package com.apress.bgn.eleven;
import java.io.*;
import java.time.LocalDate;
import java.util.Objects;
public class Singer implements Serializable {
private static final long serialVersionUID = 42L;
private String name;
private Double rating;
private LocalDate birthDate;
public Singer() {
/* required for deserialization */
}
public Singer(String name, Double rating, LocalDate birthDate) {
this.name = name;
this.rating = rating;
this.birthDate = birthDate;
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
}
private void readObjectNoData() throws ObjectStreamException {
this.name = "undefined";
this.rating = 0.0;
this.birthDate = LocalDate.now();
}
@Override
public String toString() {
return "Singer{" +
"name='" + name + '\'' +
", rating=" + rating +
", birthDate=" + birthDate +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Singer singer = (Singer) o;
return Objects.equals(name, singer.name) &&
Objects.equals(rating, singer.rating) &&
Objects.equals(birthDate, singer.birthDate);
}
@Override
public int hashCode() {
return Objects.hash(name, rating, birthDate);
}
}
Listing 11-34Serializable Singer Class
现在我们有了类,让我们实例化它,序列化它,保存到一个文件,然后将文件的内容反序列化到另一个对象中,我们将与初始对象进行比较。清单 11-35 中描述了所有这些操作。
package com.apress.bgn.eleven;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.time.LocalDate;
import java.time.Month;
public class SerializationDemo {
private static final Logger log = LoggerFactory.getLogger(SerializationDemo.class);
public static void main(String... args) throws ClassNotFoundException {
LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
Singer john = new Singer("John Mayer", 5.0, johnBd);
File file = new File("chapter11/serialization/src/test/resources/output/john.txt");
try (var out = new ObjectOutputStream(new FileOutputStream(file))){
out.writeObject(john);
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
try(var in = new ObjectInputStream(new FileInputStream(file))){
Singer copyOfJohn = (Singer) in.readObject();
log.info("Are objects equal? {}", copyOfJohn.equals(john));
log.info("--> {}", copyOfJohn);
} catch (IOException e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-35Serializing and Deserializing a Singer Class
运行前面的代码时,一切正常,分别由ObjectOutputStream、ObjectInputStream调用writeObject(..)和readObject(..)。如果您想测试它们是否被真正调用,您可以添加日志记录,或者您可以在它们内部放置断点并在调试中运行程序。如果你打开john.txt,你将无法理解太多。那里写的文本没有多大意义,因为它是二进制的原始数据。如果你打开文件,你可能会看到如图 11-7 所示的内容。
图 11-7
序列化的Singer实例
XML 序列化
然而,Java 序列化并不一定会产生加密文件。对象可以序列化为可读格式。最常用的序列化格式之一是 XML,JDK 提供了将对象转换为 XML 以及将 XML 转换回初始对象的类。Java Architecture for XML Binding(JAXB)用于提供一种快速便捷的方式来绑定 XML schemas 和 Java 表示,使 Java 开发人员可以轻松地将 XML 数据和处理功能合并到 Java 应用中。将对象序列化为 XML 的操作被命名为编组。反序列化对象形式 XML 的操作被称为解组。对于一个可序列化为 XML 的类,它必须用 JAXB 特定的注释来修饰:
-
@XmlRootElement(name = "...")是放置在类级别的顶级注释,告诉 JAXB 类名将在序列化时成为 XML 元素;如果 XML 元素需要不同的名称,可以通过 name 属性来指定。 -
@XmlElement(name = "..")是一个方法或字段级注释,用于告诉 JAXB 字段或方法名称将在序列化时成为 XML 元素;如果 XML 元素需要不同的名称,可以通过 name 属性来指定。 -
@XmlAttribute(name = "..")是一个方法或字段级别的注释,用于告诉 JAXB 字段或方法名称将在序列化时成为 XML 属性;如果 XML 属性需要不同的名称,可以通过 name 属性来指定。
JAXB 已从 JDK 11 中移除,因此如果您想使用它,必须添加外部依赖项。 4 当这本书的前一个版本写出来的时候,这个库不仅仅是有点不稳定。com.sun.xml.internal.bind.v2.ContextFactory是jaxb-impl库的一部分,当时在任何公共存储库中都找不到,至少不是用 Java 11 编译的版本。这使得配置模块成为一件痛苦的事情,因为多个依赖项导出相同的包。然而,代码在当时是有效的,因为在实践中你可能碰巧在老的项目上工作,所以知道它的存在是很好的。
清单 11-36 中描述了用 JAXB 使Singer类可序列化的代码。注意前面列出的注释是如何在类头和类公共 getters 上使用的。
package com.apress.bgn.eleven.xml;
import javax.xml.bind.annotation.XmlAttribute;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
@XmlRootElement(name = "singer")
public class Singer implements Serializable {
private static final long serialVersionUID = 42L;
private String name;
private Double rating;
private LocalDate birthDate;
public Singer() {
/* required for deserialization */
}
public Singer(String name, Double rating, LocalDate birthDate) {
this.name = name;
this.rating = rating;
this.birthDate = birthDate;
}
@XmlAttribute(name = "name")
public String getName() {
return name;
}
@XmlAttribute(name = "rating")
public Double getRating() {
return rating;
}
@XmlElement(name = "birthdate")
public LocalDate getBirthDate() {
return birthDate;
}
// other code omitted
}
Listing 11-36A Singer Class with JAXB Annotations
清单 11-37 描述了序列化Singer类实例所需的代码。
package com.apress.bgn.eleven.xml;
// other imports omitted
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;
public class JAXBSerializationDemo {
private static final Logger log = LoggerFactory.getLogger(JAXBSerializationDemo.class);
public static void main(String... args) throws ClassNotFoundException, JAXBException {
LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
Singer john = new Singer("John Mayer", 5.0, johnBd);
File file = new File("chapter11/serialization/src/main/resources/output/john.xml");
JAXBContext jaxbContext = JAXBContext.newInstance(Singer.class);
try {
Marshaller marshaller = jaxbContext.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
marshaller.marshal(john, file);
} catch (Exception e) {
log.info("Something went wrong! ", e);
}
try {
Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
Singer copyOfJohn = (Singer) unmarshaller.unmarshal(file);
log.info("Are objects equal? {}", copyOfJohn.equals(john));
log.info("--> {}", copyOfJohn.toString());
} catch (Exception e) {
log.info("Something went wrong! ", e);
}
}
}
Listing 11-37Marshalling and Unmarshalling a Singer Class with JAXB
在 JDK 17 中使用 JAXB 不是一个选项,因为社区库自 2018 年以来一直没有维护。在本书的这个版本中,选择使用最稳定、最通用和最新的库之一:Jackson 来引入 XML 序列化。 5
Jackson 在 Java 生态系统中以终极 Java JSON 库而闻名,但它有支持多种格式序列化的模块,其中包括 XML、JSON、CSV、TAML 和 YAML。只需查看项目页面;如果有一种新的引人注目的序列化格式出现,可能已经有一个模块了。
当使用 Jackson 序列化为 XML 时,需要记住一些事情。
-
有一组不同的注释可供使用,下面列出了最重要的注释:
-
@JacksonXmlRootElement(localName = "...")是一个顶级注释,放置在类级别,告诉 Jackson 类名将在序列化时成为 XML 元素;如果 XML 元素需要不同的名称,可以通过localName属性来指定。 -
@JacksonXmlProperty(localName = "...")是一个方法或者字段级的注释,用来告诉 Jackson 字段或者方法名在序列化的时候会变成一个 XML 元素;如果 XML 元素需要不同的名称,可以通过localName属性来指定。 -
当属性被配置为 XML 属性时,使用带有
isAttribute = true参数的@JacksonXmlProperty(localName = "...", isAttribute = true)。
-
-
为了用 Jackson 进行序列化和反序列化,使用了一个实例
com.fasterxml.jackson.dataformat.xml.XmlMapper。 -
必须配置
XmlMapper实例来支持特殊类型,比如新的 Java 8 Date API 类型,这是通过注册和配置com.fasterxml.jackson.datatype.jsr310.JavaTimeModule来完成的。 -
当使用 Java 模块时,你必须确保它们配置正确。异常并不总是容易理解的,解决它们可能需要结合 Maven 和模块配置来解决。
也就是说,让我们从清单 11-38 中显示的模块配置开始。
module chapter.eleven.serialization {
requires org.slf4j;
requires com.fasterxml.jackson.databind;
requires com.fasterxml.jackson.dataformat.xml;
requires com.fasterxml.jackson.datatype.jsr310;
opens com.apress.bgn.eleven.xml to com.fasterxml.jackson.databind;
}
Listing 11-38Module Configuration for XML Serialization with Jackson
需要前两个requires com.fasterxml.jackson.*指令,以便可以使用 Jackson 注释和XmlMapper。Java 8 日期 API 类型的序列化需要jsr310。
最后一个语句opens com.apress.bgn.eleven.xml to com.fasterxml.jackson.databind是必要的,这样 Jackson 就可以访问包com.apress.bgn.eleven.xml中的类,因为使用 Jackson 注释编写的Singer类的版本就位于那里。清单 11-39 中描述了该类。
package com.apress.bgn.eleven.xml;
// other imports omitted
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
@JacksonXmlRootElement(localName = "singer")
public class Singer implements Serializable {
private static final long serialVersionUID = 42L;
private String name;
private Double rating;
private LocalDate birthDate;
public Singer() {
/* required for deserialization */
}
public Singer(String name, Double rating, LocalDate birthDate) {
this.name = name;
this.rating = rating;
this.birthDate = birthDate;
}
@JacksonXmlProperty(localName = "name", isAttribute = true)
public String getName() {
return name;
}
@JacksonXmlProperty(localName = "rating", isAttribute = true)
public Double getRating() {
return rating;
}
@JacksonXmlProperty(localName = "birthdate")
public LocalDate getBirthDate() {
return birthDate;
}
// other code omitted
}
Listing 11-39A Singer Class with Jackson XML Annotations
请注意放置注释的位置。基于在序列化john对象时注释的位置和它们在前面代码中的配置,预计john.xml文件将包含清单 11-40 中描述的代码片段。
<singer name="John Mayer" rating="5.0">
<birthdate>1977-10-16</birthdate>
</singer>
Listing 11-40The john Singer Instance in XML Format
比二进制版本可读性更强,对吧?清单 11-41 描述了将Singer实例保存到john.xml文件的代码,然后它将它加载回一个副本,然后比较这两个实例。
package com.apress.bgn.eleven.xml;
// some import statements omitted
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
public class XMLSerializationDemo {
private static final Logger log = LoggerFactory.getLogger(XMLSerializationDemo.class);
public static void main(String... args) {
LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
Singer john = new Singer("John Mayer", 5.0, johnBd);
var xmlMapper = new XmlMapper();
xmlMapper.registerModule(new JavaTimeModule());
xmlMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
xmlMapper.enable(SerializationFeature.INDENT_OUTPUT);
try {
String xml = xmlMapper.writeValueAsString(john);
Files.writeString(Path.of("chapter11/serialization/src/test/resources/output/john.xml"), xml,
StandardCharsets.UTF_8);
} catch (Exception e) {
log.info("Serialization to XML failed! ", e);
}
try {
Singer copyOfJohn = xmlMapper.readValue(Path.of("chapter11/serialization/src/test/resources/output/john.xml").toFile(), Singer.class);
log.info("Are objects equal? {}", copyOfJohn.equals(john));
log.info("--> {}", copyOfJohn);
} catch (IOException e) {
log.info("Deserialization of XML failed! ", e);
}
}
}
Listing 11-41Serializing and Deserializing a Singer Class with Jackson’s XmlMapper
XmlMapper实例可以用来序列化项目中包含 Jackson 注释的任何类。在前面的示例中,它还被配置为支持 Java 8 Date API 类型的默认序列化,并保持类型可读,方法是不使用以下两行将它们转换为数字时间戳:
xmlMapper.registerModule(new JavaTimeModule());
xmlMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
因为选择的格式是 XML,如果全部写在一行中,看起来会很难看,所以使用这个语句xmlMapper.enable(SerializationFeature.INDENT_OUTPUT)支持缩进格式。
XML 序列化多年来一直主导着开发领域,被用于大多数 web 服务和远程通信。然而,随着 XML 文件变得越来越大,它们往往变得拥挤、冗余,阅读起来也很痛苦,所以一种新的格式抢了风头:JSON。
JSON 序列化
JSON 是一种轻量级的数据交换格式。它对于人类来说是可读的,对于机器来说是容易解析和生成的。JSON 是 JavaScript 应用和基于 REST 的应用中最受欢迎的数据格式,也是许多 NoSQL 数据库使用的内部格式。因此,我们向您展示如何使用这种格式来序列化/反序列化 Java 对象是非常合适的。将 Java 对象序列化为 JSON 的优点是,有多个库提供这样做的类,这意味着至少有一个库在 Java 9+版本中是稳定的。
JSON 格式本质上是密钥对值的集合。这些值可以是数组,也可以是密钥对本身的集合。JSON 序列化最喜欢的库也是 Jackson 库,因为它可以在 Java 对象和 JSON 对象之间来回转换,而不需要编写太多代码。本章最好的部分是相同的模块配置也可以用于 JSON。我们所需要的只是改变所使用的注释和用来进行序列化/反序列化的映射器的类型。Jackson 支持 JSON 序列化的大量注释,但是对于本书中的简单例子,我们真的不需要任何注释。Jackson com.fasterxml.jackson.databind.json.JsonMapper实例足够智能,可以自动检测一个类的公共可访问属性(公共字段,或者带有公共 getters 的私有字段),并在序列化/反序列化该类的实例时使用它们。
包com.fasterxml.jackson.annotation中的@JsonAutoDetect注释可以用来注释一个类。可以配置它来告诉映射器应该序列化哪些类成员。有几个选项,集中在注释体中声明的Visibility枚举中:
-
所有类型的访问修饰符(公共的、受保护的、私有的)都会被自动检测。
-
NON_PRIVATE 自动检测除了
private以外的所有修改器。 -
PROTECTED_AND_PUBLIC,只有
protected和public修饰符被自动检测。 -
自动检测 PUBLIC _ ONLY】修饰符。
-
NONE 禁用字段或方法的自动检测。在这种情况下,必须使用字段上的
@JsonProperty注释来明确完成配置。 -
根据上下文(有时从父级继承),应用默认的缺省规则。
放置在Singer类上的这个注释与适当的映射器和JavaTimeModule相结合,确保了Singer类的一个实例可以正确地序列化为 JSON 也是从 JSON 反序列化而来的。清单 11-42 显示了Singer类的简单配置(即使是冗余的)。
package com.apress.bgn.eleven.json;
// some import statements omitted
import com.fasterxml.jackson.annotation.JsonAutoDetect;
@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY)
public class Singer implements Serializable {
private static final long serialVersionUID = 42L;
private String name;
private Double rating;
private LocalDate birthDate;
public String getName() { // auto-detected
return name;
}
public Double getRating() { // auto-detected
return rating;
}
public LocalDate getBirthDate() { // auto-detected
return birthDate;
}
// other code omitted
}
Listing 11-42Annotating a Singer Class with Jackson @JsonAutoDetect Just to Show How It’s Done
为了序列化一个Singer实例,需要一个JsonMapper实例。这个类是在 Jackson 版本 2.10 中引入的。在那个版本之前,com.fasterxml.jackson.databind. ObjectMapper号被用于同样的目的。ObjectMapper旨在成为未来版本中所有映射器的根类。上一节使用的XmlMapper也扩展了ObjectMapper。JsonMapper是一个特定于 JSON 格式的ObjectMapper实现,旨在取代通用实现,清单 11-43 描述了一个如何使用它来序列化/反序列化Singer实例的示例。
package com.apress.bgn.eleven.json;
// other import statements omitted
import com.apress.bgn.eleven.xml.Singer;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
public class JSONSerializationDemo {
private static final Logger log = LoggerFactory.getLogger(JSONSerializationDemo.class);
public static void main(String... args) {
LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
com.apress.bgn.eleven.xml.Singer john = new Singer("John Mayer", 5.0, johnBd);
JsonMapper jsonMapper = new JsonMapper();
jsonMapper.registerModule(new JavaTimeModule());
jsonMapper.enable(SerializationFeature.INDENT_OUTPUT);
jsonMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
try {
String xml = jsonMapper.writeValueAsString(john);
Files.writeString(Path.of("chapter11/serialization/src/test/resources/output/john.json"), xml,
StandardCharsets.UTF_8);
} catch (Exception e) {
log.info("Serialization to XML failed! ", e);
}
try {
Singer copyOfJohn = jsonMapper.readValue(Path.of("chapter11/serialization/src/test/resources/output/john.json").toFile(), Singer.class);
log.info("Are objects equal? {}", copyOfJohn.equals(john));
log.info("--> {}", copyOfJohn);
} catch (IOException e) {
log.info("Deserialization of XML failed! ", e);
}
}
}
Listing 11-43Serializing and Deserializing a Singer Class with Jackson’s JsonMapper
正如您所看到的,除了映射器用户的类型之外,在从 XML 进行转换时,这个代码示例中没有多少变化。杰克逊很棒,对吧?
类Singer中字段birthDate是类型java.time.LocalDate。注册JavaTimeModule允许控制如何在映射器级别序列化/反序列化这种类型的字段。另一种方法是为这种类型的数据声明一个定制的序列化器和反序列化器类,并通过用@JsonSerialize和@JsonDeserialize注释注释birthDate来配置它们。清单 11-44 显示了在 birthdate 字段上配置的定制序列化器和反序列化器类。
package com.apress.bgn.eleven.json2;
// other import statements omitted
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY)
public class Singer implements Serializable {
private static final long serialVersionUID = 42L;
private String name;
private Double rating;
@JsonSerialize(converter = LocalDateTimeToStringConverter.class)
@JsonDeserialize(converter = StringToLocalDatetimeConverter.class)
private LocalDate birthDate;
// other code omitted
}
Listing 11-44Configuring Custom Serialization and Deserialization for java.time.LocalDate Fields
清单 11-45 显示了两个序列化器和反序列化器类的定制实现。
package com.apress.bgn.eleven.json2;
import com.fasterxml.jackson.databind.util.StdConverter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
class LocalDateTimeToStringConverter extends StdConverter<LocalDateTime, String> {
static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
@Override
public String convert(LocalDateTime value) {
return value.format(DATE_FORMATTER);
}
}
class StringToLocalDatetimeConverter extends StdConverter<String, LocalDateTime> {
@Override
public LocalDateTime convert(String value) {
return LocalDateTime.parse(value, LocalDateTimeToStringConverter.DATE_FORMATTER);
}
}
Listing 11-45Custom Serialization and Deserialization Classes
关于与 Jackson 的 JSON 序列化,只能说这些。如果这个主题对你有吸引力,你可以自己多读一些。
还有一个用于将 Java 实例序列化到 YAML 的 Jackson 库,它是在配置文件方面的新人。该库被命名为
jackson-dataformat-yaml。
媒体 API
除了文本数据,Java 还可以用来操作二进制文件,比如图像。Java Media API 包含一组图像编码器/解码器(codec)类,用于几种流行的图像存储格式:BMP、GIF(仅限解码器)、FlashPix(仅限解码器)、JPEG、PNG、PNM3、TIFF 和 WBMP。
在 Java 9 中,Java media API 也进行了转换,增加了将许多不同分辨率的图像封装成多分辨率图像的功能。Java Media API 的核心是java.awt.Image抽象类,它是用于表示图形图像的所有类的超类。图 11-8 中描述了代表类的最重要的图像以及它们之间的关系。
图 11-8
Image类层次结构(如 IntelliJ IDEA 所示)
虽然java.awt.Image类是这个层次结构中的根类,但使用最多的是java.awt.BufferedImage,它是一个具有可访问图像数据缓冲区的实现。它提供了许多方法,可以用来创建图像、设置图像大小和内容、提取图像内容并进行分析,等等。在这一节中,我们将利用这个类来读写图像。
图像文件是一个复杂的文件。除了图片本身,它还包含许多附加信息;如今最重要的是这幅画的创作地点。如果你想知道一个社交网络是如何为你发布的图片建议一个签到位置的,这里就是找到信息的地方。这可能看起来不那么重要,但是贴一张在你家拍的猫的照片会让全世界都知道你的位置。我不知道你怎么想,但对我来说这太可怕了。我曾经把我的猫舒适地坐在我写这本书的电脑上的照片贴在我的个人博客上,这意味着我基本上把我和一台相当昂贵的笔记本电脑的位置暴露给了全世界。当然,大多数人不关心我的猫,也不关心我的笔记本电脑,但一些想轻松赚钱的人可能会。因此,在一位友好而博学的读者给我发了一封私人邮件,告诉我一种叫做EXIF的数据,以及他是如何因为我在博客上发布的最后一张照片而知道我住在哪里之后,我调查了一下。一张照片的EXIF数据包含大量关于你的相机和照片拍摄地点的信息(GPS 坐标)。大多数智能手机将EXIF数据嵌入到相机拍摄的照片中。
在图 11-9 中,你可以看到 macOS 预览应用描述的EXIF信息。
图 11-9
JPG 图像上的 EXIF 信息
请注意,EXIF信息包含照片拍摄的确切位置、纬度和经度。EXIF代表可交换图像文件格式,有实用程序可以删除它,但当你在博客上发布大量图片时(像我一样),逐一清理它们会花费太多时间。这就是 Java 的用武之地,我将与你分享我用来清理我的EXIF数据图片的一段代码(清单 11-46 )。
package com.apress.bgn.eleven;
// some import statement omitted
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;
import java.awt.image.MultiResolutionImage;
/**
* Created by iuliana.cosmina on 23/07/2021
*/
public class MediaDemo {
private static final Logger log = LoggerFactory.getLogger(MediaDemo.class);
public static void main(String... args) {
File src = new File("chapter11/media-handling/src/main/resources/input/the-beach.jpg");
try {
log.info(" --- Removing EXIF info ---");
File destNoExif = new File("chapter11/media-handling/src/main/resources/output/the-beach.jpg");
removeExifTag(src, destNoExif);
} catch (Exception e) {
log.error("Something bad happened.", e);
}
}
private static void removeExifTag(final File src, final File dest) throws Exception {
BufferedImage originalImage = ImageIO.read(src);
ImageIO.write(originalImage, "jpg", dest);
}
}
Listing 11-46Code Snippet to Strip EXIF Data from Images
删除EXIF数据非常容易,因为javax.imageio.ImageIO不会在图像文件中保存EXIF信息或任何其他没有链接到实际图像的信息。
在本书的前一版本中,使用了阿帕奇 Sanselan。这个实用程序库提供了能够以更好的性能剥离EXIF信息的类,但不幸的是,它目前没有被维护,并且不能用于模块化应用。
removeExifTag(..)方法作为一个参数给出,即图像的来源和一个管理新图像保存位置的File处理程序。要测试生成的图像是否没有EXIF数据,只需在图像查看器中打开它。任何显示EXIF的选项要么被禁用,要么不显示任何内容。在 macOS 的预览图像浏览器中,该选项是灰色的。
现在我们已经解决了这个问题,让我们调整原始图像的大小。为了调整图像的大小,我们需要从原始图像创建一个BufferedImage实例来获取图像的尺寸。之后,我们修改维度并使用它们作为参数来创建一个新的BufferedImage,它将由一个java.awt.Graphics2D实例填充数据,这是一个特殊类型的类,用于呈现二维形状、文本和图像。清单 11-47 中描述了代码。调用该方法创建一个缩小 25%的图像、一个缩小 50%的图像和一个缩小 75%的图像。
package com.apress.bgn.eleven;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;
public class MediaDemo {
private static final Logger log = LoggerFactory.getLogger(MediaDemo.class);
public static void main(String... args) {
File src = new File("chapter11/media-handling/src/main/resources/input/the-beach.jpg");
try {
log.info(" --- Creating 25% image ---");
File dest25 = new File("chapter11/media-handling/src/main/resources/output/the-beach_25.jpg");
resize(dest25, src, 0.25f);
log.info(" --- Creating 50% image ---");
File dest50 = new File("chapter11/media-handling/src/main/resources/output/the-beach_50.jpg");
resize(dest50, src, 0.5f);
log.info(" --- Creating 75% image ---");
File dest75 = new File("chapter11/media-handling/src/main/resources/output/the-beach_75.jpg");
resize(dest75, src, 0.75f);
} catch (Exception e) {
log.error("Something bad happened.", e);
}
}
private static void resize(final File dest, final File src, final float percent) throws IOException {
BufferedImage originalImage = ImageIO.read(src);
int scaledWidth = (int) (originalImage.getWidth() * percent);
int scaledHeight = (int) (originalImage.getHeight() * percent);
BufferedImage outputImage = new BufferedImage(scaledWidth, scaledHeight, originalImage.getType());
Graphics2D g2d = outputImage.createGraphics();
g2d.drawImage(originalImage, 0, 0, scaledWidth, scaledHeight, null);
g2d.dispose();
outputImage.flush();
ImageIO.write(outputImage, "jpg", dest);
}
}
Listing 11-47Code Snippet to Resize an Image
为了使事情变得更简单,ImageIO类实用程序方法在从文件中读取图像或将图像写入特定位置时非常方便。如果您想测试一下调整大小是否有效,您可以查看一下resources目录。输出文件已经被相应地命名,但是为了确保正确,您可以在文件查看器中仔细检查。你应该会看到类似于图 11-10 中描述的东西。
图 11-10
使用 Java 代码调整大小的图像
结果图像的质量不如原始图像高,因为压缩像素不会产生高质量,但它们确实符合我们的预期大小。
现在我们有了同一幅图像的所有版本,我们可以使用 Java 9 中引入的类BaseMultiResolutionImage来创建多分辨率图像。这个类的一个实例是从一组图像中创建的,所有图像都是一个图像的副本,但是分辨率不同。这就是为什么之前我们创建了不止一个图片的副本。一个BaseMultiResolutionImage可以用来检索基于特定屏幕分辨率的图像,它适用于设计为从多个设备访问的应用。让我们先看看代码,然后解释结果(列出 11-48 )。
package com.apress.bgn.eleven;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;
import java.awt.image.MultiResolutionImage;
import java.io.File;
import java.io.IOException;
public class MediaDemo {
private static final Logger log = LoggerFactory.getLogger(MediaDemo.class);
public static void main(String... args) {
File src = new File("chapter11/media-handling/src/main/resources/input/the-beach.jpg");
try {
// code to create images omitted, check previous Listing
Image[] imgList = new Image[]{
ImageIO.read(dest25), // 500 x 243
ImageIO.read(dest50), // 1000 x 486
ImageIO.read(dest75), // 1500 x 729
ImageIO.read(src) // 2000 x 972
};
log.info(" --- Creating multi-resolution image ---");
File destVariant = new File("chapter11/media-handling/src/main/resources/output/the-beach-variant.jpg");
createMultiResImage(destVariant, imgList);
BufferedImage variantImg = ImageIO.read(destVariant);
log.info("Variant width x height : {} x {}", variantImg.getWidth(), variantImg.getHeight());
BufferedImage dest25Img = ImageIO.read(dest25);
log.info("dest25Img width x height : {} x {}", dest25Img.getWidth(), dest25Img.getHeight());
log.info("Are identical? {}", variantImg.equals(dest25Img));
} catch (Exception e) {
log.error("Something bad happened.", e);
}
}
private static void createMultiResImage(final File dest, final Image[] imgList) throws IOException {
MultiResolutionImage mrImage = new BaseMultiResolutionImage(0,imgList);
var variants = mrImage.getResolutionVariants();
variants.forEach(i -> log.info(i.toString()));
Image img = mrImage.getResolutionVariant(500, 200);
log.info("Most fit to the requested size<{},{}>: <{},{}>", 500, 200, img.getWidth(null), img.getHeight(null));
if (img instanceof BufferedImage) {
ImageIO.write((BufferedImage) img, "jpg", dest);
}
}
}
Listing 11-48Code Snippet to Create a Multiresolution Image
从一组Image实例中创建BaseMultiResolutionImage实例。这个类是 MultiResolutionImage 接口的一个实现,它是一个可选的附加 API,由 Image 的一些实现支持,允许它们为各种渲染分辨率提供替代图像。
为了清楚地显示哪张图像将被选中,每张图像的分辨率都被放在旁边的注释中。当调用getResolutionVariant(..)时,将参数与相应的图像属性进行比较,即使两者都小于等于其中一个图像,也将返回该图像。在清单 11-49 中,描述了BaseMultiResolutionImage.getResolutionVariant(..)的代码:
@Override
public Image getResolutionVariant(double destImageWidth,
double destImageHeight) {
checkSize(destImageWidth, destImageHeight);
for (Image rvImage : resolutionVariants) {
if (destImageWidth <= rvImage.getWidth(null)
&& destImageHeight <= rvImage.getHeight(null)) {
return rvImage;
}
}
return resolutionVariants[resolutionVariants.length - 1];
}
Listing 11-49Code for Getting an Image Variant Based on Size
代码看起来符合它的目的。如果您调用mrImage.getResolutionVariant(500, 200),您将获得分辨率为 500 x 243 的dest25图像。如果您调用mrImage.getResolutionVariant(500, 300),您将得到分辨率为 1000 x 486 的dest50图像,因为destImageHeight参数是 300,大于 243,所以返回列表中宽度和高度大于参数的下一个图像。但是——这是一个很大的问题——只有当数组中的图像按大小排序时,这种方法才有效。如果imgList被修改为:
Image[] imgList = new Image[]{
ImageIO.read(src), // 2000 x 972
ImageIO.read(dest25), // 500 x 243
ImageIO.read(dest50), // 1000 x 486
ImageIO.read(dest75) // 1500 x 729
};
然后两个调用都返回原始图像,因为这是列表中的第一个图像,宽度大于 500,高度大于 200 和 300。
因此,如果算法效率不高,并且它依赖于用于创建多分辨率图像的数组中图像的顺序,那么可以做些什么呢?很简单:我们可以创建自己的MultiResolutionImage实现,扩展BaseMultiResolutionImage并覆盖getResolutionVariant()方法。因为我们知道所有的图像都是相同图像的副本,这意味着宽度和高度是成比例的。因此,可以编写一种算法,该算法将总是返回最适合所需分辨率的图像变体,而不会真正关心图像在阵列中的顺序,并且将返回最适合的图像。该实现可能看起来与清单 11-50 中的非常相似。
package com.apress.bgn.eleven;
// other import statements omitted
import java.awt.image.BaseMultiResolutionImage;
public class SmartMultiResolutionImage extends BaseMultiResolutionImage {
public SmartMultiResolutionImage(int baseImageIndex, Image... resolutionVariants) {
super(baseImageIndex, resolutionVariants);
}
@Override
public Image getResolutionVariant(double destImageWidth,
double destImageHeight) {
checkSize(destImageWidth, destImageHeight);
Map<Double, Image> result = new HashMap<>();
for (Image rvImage : getResolutionVariants()) {
double widthDelta = Math.abs(destImageWidth - rvImage.getWidth(null));
double heightDelta = Math.abs(destImageHeight - rvImage.getHeight(null));
double delta = widthDelta + heightDelta;
result.put(delta, rvImage);
}
java.util.List<Double> deltaList = new ArrayList<>(result.keySet());
deltaList.sort(Double::compare);
return result.get(deltaList.get(0));
}
private static void checkSize(double width, double height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException(String.format(
"Width (%s) or height (%s) cannot be <= 0", width, height));
}
if (!Double.isFinite(width) || !Double.isFinite(height)) {
throw new IllegalArgumentException(String.format(
"Width (%s) or height (%s) is not finite", width, height));
}
}
}
Listing 11-50Better Code for Getting an Image Variant Based on Size
必须复制checkSize(..)方法,因为它是私有的,并且在getResolutionVariant(..)内部使用,所以不能在超类内部调用,但是这对于具有适当行为的实现来说是一个小小的不便。有了前面的实现,我们不再需要排序后的数组,因此对getResolutionVariant(500, 200), getResolutionVariant(500, 300)、getResolutionVariant(400, 300),和getResolutionVariant(600, 300)的调用都返回图像dest25。
要使用这个新类,在清单 11-48 中的这一行:
MultiResolutionImage mrImage = new BaseMultiResolutionImage(0,imgList);
必须替换为
MultiResolutionImage mrImage = new SmartMultiResolutionImage(0, imgList);
如果你想正确地测试它,你也可以在imgList数组中重新定位图像。然后运行MediaDemo类产生清单 11-51 中描述的输出。
[main] INFO MediaDemo - --- Creating multi-resolution image ---
[main] INFO MediaDemo - BufferedImage@47c62251: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 2000 height = 972 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - BufferedImage@3c0ecd4b: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 500 height = 243 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - BufferedImage@14bf9759: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 1000 height = 486 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - BufferedImage@5f341870: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 1500 height = 729 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - Most fit to the requested size<500,200>: <500,243>
[main] INFO MediaDemo - Are identical? false
Listing 11-51Output Produced By Running the MediaDemo
等等什么?为什么图像不一样?它们确实有相同的分辨率,但作为对象,它们并不相同,因为绘制像素并不真的那么精确。但是如果你真的想确定,你可以打印两幅图像的宽度和高度,用图像浏览器打开它们,用肉眼你会看到它们看起来是一样的,使用这样的代码:
log.info("variant width x height : {} x {}", variantImg.getWidth(),
variantImg.getHeight());
log.info("dest25Img width x height : {} x {}", dest25Img.getWidth(),
dest25Img.getHeight());
前面的代码打印了两个图像的宽度和高度,很明显这两个图像具有相同的尺寸,正如预期的那样。
[main] INFO MediaDemo - variant width x height : 500 x 243
[main] INFO MediaDemo - dest25Img width x height : 500 x 243
无论如何,正如你已经注意到的,大多数图像类都是旧的java.awt的一部分,现在很少使用,而且众所周知非常慢。因此,如果您想要构建一个应用,并且需要图像处理,您可能需要寻找替代方案。其中一种方法是使用 JavaFx,这将在下一节中介绍。
使用 JavaFX 图像类
除了以java.awt包的组件为中心的 Java Media API 之外,JavaFX 还提供了另一种显示和编辑图像的方法。javafx.scene.image包的核心类名为Image,可以用来处理几种常见格式的图像:PNG、JPEG、BMP、GIF 等。JavaFX 应用使用一个javafx.scene.image.ImageView实例显示图像,我最喜欢这个类的一点是,图像也可以缩放显示,而不需要修改原始图像。
要创建一个javafx.scene.image.Image实例,我们需要的要么是一个从用户提供的位置读取图像的FileInputStream实例,要么是一个给定为String的 URL 位置。清单 11-52 中的代码片段创建了一个 JavaFX 应用,该应用显示具有原始宽度和高度的图像,可以使用类javafx.scene.image.Image中的方法来访问该图像。
package com.apress.bgn.eleven;
import javafx.application.Application;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import java.io.File;
import java.io.FileInputStream;
public class JavaFxMediaDemo extends Application {
public static void main(String... args) {
Application.launch(args);
}
@Override
public void start(Stage primaryStage) throws Exception {
primaryStage.setTitle("JavaFX Image Demo");
File src = new File("chapter11/media-handling/src/main/resources/cover.png");
Image image = new Image(new FileInputStream(src));
ImageView imageView = new ImageView(image);
imageView.setFitHeight(image.getHeight());
imageView.setFitWidth(image.getWidth());
imageView.setPreserveRatio(true);
//Creating a Group object
StackPane root = new StackPane();
root.getChildren().add(imageView);
primaryStage.setScene(new Scene(root,
image.getWidth()+10,
image.getHeight()+10));
primaryStage.show();
}
}
Listing 11-52Using JavaFX to Display Images
不能将Image实例直接添加到 JavaFX 实例的Scene中,因为它没有扩展构成JavaFxApplication的所有 JavaFX 元素需要实现的Node抽象类。这就是为什么这个实例必须被包装在一个javafx.scene.image.ImageView实例中,这个实例是一个扩展Node,的类,它是一个用于渲染加载了Image类的图像的专用类。
这个类通过调用带有适当参数的setPreserveRatio(..)方法来调整显示图像的大小,保留或不保留原始纵横比,调用true来保持原始纵横比,否则调用false。
查看章节 ** 10 ** 了解如何为您的系统安装 JavaFX,以便本章中的示例可以正确运行。
如你所见,在前面的代码中,我们使用由image.getWidth()和image.getHeight()返回的值来设置ImageView对象的大小和Scene实例的大小。但是让我们发挥创意,显示缩放后的图像,仍然保持纵横比,并且在使用smooth(..)方法缩放图像时使用更高质量的过滤算法,如下所示。
...
ImageView imageView = new ImageView(image);
imageView.setFitWidth(100);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
...
ImageView类可以做的另一件事是支持一个可以用来旋转图像的Rectangle2D视口。
...
ImageView imageView = new ImageView(image);
Rectangle2D viewportRect = new Rectangle2D(2, 2, 600, 600);
imageView.setViewport(viewportRect);
imageView.setRotate(90);
...
作为 node ImageView的一个实现,它支持点击事件,并且很容易编写一些代码来在点击时调整图像的大小。看看清单 11-53 中的代码就知道了。
...
ImageView imageView = new ImageView(image);
imageView.setFitHeight(image.getHeight());
imageView.setFitWidth(image.getWidth());
imageView.setPreserveRatio(true);
root.getChildren().add(imageView);
imageView.setPickOnBounds(true);
imageView.setOnMouseClicked(mouseEvent -> {
if(imageView.getFitWidth() > 100) {
imageView.setFitWidth(100);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
} else {
imageView.setFitHeight(image.getHeight());
imageView.setFitWidth(image.getWidth());
imageView.setPreserveRatio(true);
}
});
...
Listing 11-53Using JavaFX to Resize Images on Click Events
在前面的代码片段中,我们通过调用setOnMouseClicked(..)将一个EventHandler<? super MouseEvent>实例附加到 imageView 上的鼠标点击事件。EventHandler<T extends MouseEvent>是一个函数接口,包含一个名为 handle 的方法,它的具体实现是前面代码清单中 lambda 表达式的主体。
由于 JavaFX 已从 JDK 11 中删除,所以在本节中讨论更多的图像处理类没有什么实际价值。但是如果你有兴趣学习更多关于这个主题的知识,甲骨文的这个教程应该可以胜任这个工作: https://docs.oracle.com/javafx/2/image_ops/jfxpub-image_ops.htm 。此外,作为练习,您可以尝试根据书中的代码编写自己的代码,以添加旋转图像的鼠标事件。这是我们在 Java 中可以用来玩图像的所有空间。我希望这一节对您有用,并且您将来可能有机会测试您的 Java Media API 技能——如果不是为了别的,至少是为了从您的图像中清除 EXIF 数据。
摘要
本章涵盖了您需要了解的大多数细节,以便能够处理各种类型的文件,如何序列化 Java 对象并将它们保存到文件中,然后通过反序列化恢复它们。在编写 Java 应用时,你很可能需要将数据保存到文件中或者从文件中读取数据,这一章提供了大量的组件来完成这些工作。这是本章的简短总结:
-
如何使用文件和路径实例
-
如何在文件和路径中使用实用程序方法
-
如何在二进制、XML 和 JSON 之间序列化/反序列化 Java 对象
-
如何使用 Java Media API 调整图像大小和修改图像
-
如何在 JavaFX 应用中使用图像
参见甲骨文官方 java.io 包 Javadoc 页面, https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/package-summary.html ;参见甲骨文官方 java.nio 包 Javadoc 页面, https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/package-summary.html ,均于 2021 年 10 月 15 日访问。
2
参见 Apache Commons 官方页面,“Apache Commons IO”, https://commons.apache.org/proper/commons-io ,2021 年 10 月 15 日访问。
3
在 OpenJDK, http://openjdk.java.net/projects/amber 见琥珀计划官方页面,2021 年 10 月 15 日访问。
4
它的继任者 JAXB2 现在是 JEE 的一部分;参见 Java 企业版,JAXB,“ https://javaee.github.io/jaxb-v2/ ,2021 年 10 月 15 日访问。
5
参见 Github 官方页面,“FasterXML Jackson”, https://github.com/FasterXML/jackson ,2021 年 10 月 15 日访问。