开源技术 * IBM 微讲堂:Kubeflow 系列(观看回放 | 下载讲义) 了解详情

构造真实的应用程序,第 2 部分

开始之前

本教程是 Java 编程入门 系列的一部分。

级别 主题 类型
101 设置您的 Java 开发环境并学习基本的面向对象编程的原理 教程
102 Java 语言基础 教程
103 编写良好的 Java 代码 教程
201 构造真实的应用程序,第 1 部分 教程
202 构造真实的应用程序,第 2 部分 教程

尽管各单元中讨论的概念具有独立性,但实践组件是在您学习各单元的过程中逐步建立起来的,推荐您在继续学习之前复习前提条件、设置和单元细节

您可以在我的 GitHub 代码库中的找到本教程中的代码示例。 您可以在 src 树中的代码中找到本教程中的示例(按本教程的各个部分细分,如正则表达式、泛型等)。 您可以在 test 树中的代码中找到可以运行示例的 JUnit 测试。

目标

Java 语言非常成熟和复杂,可帮助您完成几乎任何编程任务。本教程介绍了在处理复杂的编程场景时所需的 Java 语言特性,包括:

  • 正则表达式
  • 泛型
  • enum 类型
  • I/O
  • 序列化

前提条件

本教程的内容适合不熟悉 Java 语言中各种更复杂特性的初级 Java 语言程序员。本教程假设您已有:

正则表达式

正则表达式 基本来讲是一种模式,描述一组具有该共同模式的字符串。如果您是 Perl 程序员,应该非常熟悉 Java 语言中的正则表达式 (regex) 模式语法。但是,如果您不习惯使用正则表达式语法,它可能看起来很怪异。本节指导您在 Java 程序中使用正则表达式。

Regular Expressions API

下面是一组具有某些共性的字符串:

  • A string
  • A longer string
  • A much longer string

注意,这些字符串中的每一个都以 a 开头,以 string 结尾。Java Regular Expressions API 可帮助您将这些元素提取出来,查看它们之间的模式,然后使用已收集的信息做有趣的工作。

Regular Expressions API 有 3 个您几乎总是在使用的核心类:

  • Pattern 描述一种字符串模式。
  • Matcher 测试一个字符串,查看它是否与模式匹配。
  • PatternSyntaxException 告诉您,无法接受您尝试定义的模式的某个方面。

很快您就会开始使用简单的正则表达式模式,该模式使用了这些类。但是在这么做之前,看看 regex 模式语法。

regex 模式语法

regex 模式 描述字符串的结构,表达式会尝试在输入字符串中查找该结构。这是正则表达式可能看起来有点奇怪的地方。但是,一旦您理解了该语法,就可以更轻松地解释它。表 1 列出了在模式字符串中使用的一些最常见的 regex 构造:

表 1. 常见 regex 构造
Regex 构造 符合匹配条件的内容
. 任何字符
? 前面的零 (0) 或一 (1) 个字符或数字
* 前面的零 (0) 或更多个字符或数字
+ 前面的一 (1) 或更多个字符或数字
[] 一个字符或数字范围
^ 后面的条件的否定(即”非 后面的条件 ”)
\d 任何数字(也可表示为 [0-9]
\D 任何非数字(也可表示为 [^0-9]
\s 任何空格字符(也可表示为 [\n\t\f\r]
\S 任何非空格字符(也可表示为 [^\n\t\f\r]
\w 任何单词字符(也可表示为 [a-zA-Z_0-9]
\W 任何非单词字符(也可表示为 [^\w]

前几种构造称为 量词 ,因为它们对之前的内容进行量化。\d 等构造是预定义的字符类。任何在一种模式中没有特殊含义的字符都是文字并与自身匹配。

模式匹配

掌握 表 1 中的模式语法后,就能理解清单 1 中的简单示例了,这个示例使用了 Java Regular Expressions API 中的类。

清单 1. 使用 regex 进行模式匹配
Pattern pattern = Pattern.compile("a.*string");
  Matcher matcher = pattern.matcher("a string");
  boolean didMatch = matcher.matches();
  Logger.getAnonymousLogger().info (didMatch);
  int patternStartIndex = matcher.start();
  Logger.getAnonymousLogger().info (patternStartIndex);
  int patternEndIndex = matcher.end();
  Logger.getAnonymousLogger().info (patternEndIndex);

首先,清单 1 调用 compile()Pattern 上的一个静态方法)来创建一个 Pattern 类,并使用一个字符串文字来表示想要匹配的模式。该文字使用了 regex 模式语法。在本例中,该模式的中文翻译为:

_找到一个具有以下形式的字符串:Aa 后跟零或更多个字符,然后是 string

匹配方法

接下来,清单 1Pattern 上调用 matcher()。该调用创建一个 Matcher 实例。 然后 Matcher 搜索您传入的字符串,寻找与您在创建 Pattern 时使用的模式字符串相匹配的结果。

每个 Java 语言字符串都是一个带索引的字符集合,索引从 0 开始,到字符串长度减 1 结束。Matcher 从 0 开始解析该字符串,寻找与它匹配的结果。处理完成后,Matcher 包含有关在输入字符串中找到(或未找到)匹配结果的信息。可在 Matcher 上调用各种方法来访问该信息:

  • matches() 告诉您整个输入序列是否与该模式准确匹配。
  • start() 告诉您匹配的字符串在输入字符串中开始处的索引值。
  • end() 告诉您匹配的字符串在输入字符串中结束处的索引值加 1 的结果。

清单 1 找到了一个从 0 开始,到 7 结束的匹配结果。因此,调用 matches() 会返回 true,调用 start() 会返回 0,调用 end() 会返回 8

lookingAt() 与 matches()

如果字符串中的元素比搜索模式中的字符要多,可使用 lookingAt() 代替 matches()lookingAt() 搜索与给定模式匹配的子字符串。例如,考虑下面这个字符串:

Here is a string with more than just the pattern.

您可在此字符串中搜索 a.*string,而且如果使用 lookingAt(),就会获得一个匹配结果。但是如果使用 matches(),就会返回 false,因为字符串中包含的内容比模式中的多。

regex 中的复杂模式

可使用 regex 类轻松完成简单的搜索,同时也可使用 Regular Expressions API 执行非常复杂的操作。

Wiki 几乎完全基于正则表达式。Wiki 内容基于用户的字符串输入,并且使用正则表达式来解析和格式化该输入。 任何用户都可输入一个 wiki 词组,从而在 wiki 中创建另一个主题的链接,这个词组通常是一系列串联的单词,每个单词以一个大写字母开头,类似这样:

MyWikiWord

假设一个用户输入下面这个字符串:

Here is a WikiWord followed by AnotherWikiWord, then YetAnotherWikiWord.

您可使用 regex 模式在这个字符串中搜索 wiki 单词,类似这样:

[A-Z][a-z]*([A-Z][a-z]*)+

下面是搜索 wiki 单词的代码:

String input = "Here is a WikiWord followed by AnotherWikiWord, then SomeWikiWord.";
Pattern pattern = Pattern.compile("[A-Z][a-z]*([A-Z][a-z]*)+");
Matcher matcher = pattern.matcher(input);
while (matcher.find()) {
  Logger.getAnonymousLogger().info("Found this wiki word:" + matcher.group());
}

运行此代码,应在控制台中看到 3 个 wiki 单词。

替换字符串

搜索匹配内容很有用,但也可在找到匹配字符串后操作它们。您可将匹配的字符串替换为其他字符串,就像在文字处理程序中搜索一些文本并将它替换为其他文本一样。Matcher 有两个替换字符串元素的方法:

  • replaceAll() 将所有匹配值替换为一个指定的字符串。
  • replaceFirst() 仅将第一个匹配值替换为一个指定的字符串。

使用 Matcherreplace 方法很简单:

String input = "Here is a WikiWord followed by AnotherWikiWord, then SomeWikiWord.";
Pattern pattern = Pattern.compile("[A-Z][a-z]*([A-Z][a-z]*)+");
Matcher matcher = pattern.matcher(input);
Logger.getAnonymousLogger().info("Before:" + input);
String result = matcher.replaceAll("replacement");
Logger.getAnonymousLogger().info("After:" + result);

与之前一样,此代码会查找 wiki 单词。Matcher 找到一个匹配值时,它会将该 wiki 单词文本替换为相应的替换值。运行该代码时,可在控制台上看到以下消息:

Before:Here is WikiWord followed by AnotherWikiWord, then SomeWikiWord.
  After:Here is replacement followed by replacement, then replacement.

如果使用 replaceFirst(),则会看到此消息:

Before:Here is a WikiWord followed by AnotherWikiWord, then SomeWikiWord.
  After:Here is a replacement followed by AnotherWikiWord, then SomeWikiWord.

匹配和操作分组

搜索一个 regex 模式的匹配结果时,可获得有关找到的结果的信息。您已在 Matcherstart()end() 方法上看到过该功能。但也可以通过捕获 分组 来引用匹配值。

在每种模式中,通常通过将模式的各部分放在圆括号中来创建分组。分组从左向右编号,从 1 开始编号(分组 0 表示完整的匹配结果)。清单 2 中的代码将每个 wiki 单词替换为一个“包含”该单词的字符串:

清单 2. 匹配分组
String input = "Here is a WikiWord followed by AnotherWikiWord, then SomeWikiWord.";
Pattern pattern = Pattern.compile("[A-Z][a-z]*([A-Z][a-z]*)+");
Matcher matcher = pattern.matcher(input);
Logger.getAnonymousLogger().info("Before:" + input);
String result = matcher.replaceAll("blah$0blah");
Logger.getAnonymousLogger().info("After:" + result);

运行清单 2 代码,应获得以下控制台输出:

Before:Here is a WikiWord followed by AnotherWikiWord, then SomeWikiWord.
  After:Here is a blahWikiWordblah followed by blahAnotherWikiWordblah,
  then blahSomeWikiWordblah.

另一种匹配分组的方法

清单 2 通过在替换字符串中包含 $0 来引用整个匹配结果。$ _int 格式的替换字符串的任何部分引用该整数所标识的分组(所以 $1 引用分组 1,依此类推)。换句话说,$0 等效于 matcher.group(0);

也可使用其他方法实现同样的替换目标。无需调用 replaceAll(),可以这样做:

StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
  matcher.appendReplacement(buffer, "blah$0blah");
}
matcher.appendTail(buffer);
Logger.getAnonymousLogger().info("After:" + buffer.toString());

也会获得同样的结果:

Before:Here is a WikiWord followed by AnotherWikiWord, then SomeWikiWord.
  After:Here is a blahWikiWordblah followed by blahAnotherWikiWordblah,
  then blahSomeWikiWordblah.

泛型

JDK 5(2004 年发布)中引入的泛型标志着 Java 语言的一次巨大进步。如果使用过 C++ 模板,会发现 Java 语言中的泛型与其很相似,但并非完全相同。如果未使用过 C++ 模板,不要担心:本节将概括介绍 Java 语言中的泛型。

什么是泛型?

JDK 5.0(2004 年发布)向 Java 语言中引入了 泛型类型泛型 )和关联的语法。基本上讲,一些当时熟悉的 JDK 类被替换为了等效的泛型。泛型是一种编译器机制,您可通过该机制获取通用的代码并 参数化(或 模板化 )剩余部分,从而以一种一般化方式创建(和使用)一些类型的实体(比如类或接口和方法)。这种编程方法被称为 泛型编程

泛型实战

要了解泛型有何作用,可考虑一个在 JDK 中存在已久的类示例:java.util.ArrayList,它是一个由数组支持的 ObjectList

清单 3 展示了如何实例化 java.util.ArrayList

清单 3. 实例化 ArrayList
ArrayList arrayList = new ArrayList();
arrayList.add("A String");
arrayList.add(new Integer(10));
arrayList.add("Another String");
// So far, so good.

可以看到,ArrayList 具有不同的形式:它包含两个 String 类型和一个 Integer 类型。在 JDK 5 之前,Java 语言对此行为没有任何约束,这导致了许多编码错误。举例而言,在清单 23 中,目前为止看起来一切正常。但要访问 ArrayList 中的元素怎么办,清单 4 尝试采用哪种方法?

清单 4. 尝试访问 ArrayList 中的元素
ArrayList arrayList = new ArrayList();
arrayList.add("A String");
arrayList.add(new Integer(10));
arrayList.add("Another String");
// So far, so good.
*processArrayList(arrayList);
*// In some later part of the code...
private void processArrayList(ArrayList theList) {
  for (int aa = 0; aa < theList.size(); aa++) {
    // At some point, this will fail...
    String s = (String)theList.get(aa);
  }
}

如果以前不知道 ArrayList 中包含的内容,就必须检查要访问的元素,看看您是否能处理其类型,否则可能遇到 ClassCastException

借助泛型,可指定将哪些类型的内容放入 ArrayList。清单 5 展示了如何执行该操作,以及如果尝试添加错误类型的对象(第 3 行)会发生什么情况。

清单 5. 第二次尝试使用泛型
ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("A String");
arrayList.add(new Integer(10));// compiler error!
arrayList.add("Another String");
// So far, so good.
*processArrayList(arrayList);
*// In some later part of the code...
private void processArrayList(ArrayList<String> theList) {
  for (int aa = 0; aa < theList.size(); aa++) {
    // No cast necessary...
    String s = theList.get(aa);
  }
}

迭代泛型

泛型使用处理一些实体(比如 List)的特殊语法增强了 Java 语言,您通常可能希望逐个元素地处理这些实体。举例而言,如果想迭代 ArrayList,可以清单 5 中的代码重写为:

private void processArrayList(ArrayList<String> theList) {
  for (String s : theList) {
    String s = theList.get(aa);
  }
}

此语法适用于任何 Iterable (也就是实现了 Iterable 接口)的对象类型。

参数化的类

参数化的类对于集合非常有用,所以使用集合时可考虑使用这种类。考虑(真实的)List 接口,它表示一个有序的对象集合。在最常见的用例中,您向 List 中添加项,然后按索引或通过迭代 List 来访问这些项。

如果考虑参数化一个类,可考虑是否满足以下条件:

  • 一个核心类位于某种包装器的中心。也就是位于类中心的”东西”可能应用很广泛,并且其特性(例如属性)是相同的。
  • 相同的行为:无论类中心的”事务”是什么,您都会执行完全相同的操作。

根据这两个条件,显然集合满足要求:

  • 这个”事务”就是组成集合的类。
  • 操作(比如 addremovesizeclear )完全相同,无论集合由哪些对象组成都是如此。

一个参数化的 List

在泛型语法中,创建 List 的代码类似于:

List<E> listReference = new concreteListClass<E>();

E(代表元素)是我之前提到的”事务”。concreteListClass 是您正在实例化的 JDK 的类。该 JDK 包含多个 List<E> 实现,但您使用 ArrayList<E>。您可能看到的泛型类的另一种形式为 Class<T>,其中 T 代表类型。在 Java 代码中看到 E 时,它通常是指一个某种类型的集合。看到 T 时,它表示一个参数化的类。

所以,要创建一个由 java.lang.Integer 组成的 ArrayList,可以这么做:

List<Integer> listOfIntegers = new ArrayList<Integer>();

SimpleList`:一个参数化的类

现在假设您想创建自己的参数化类 SimpleList,该类包含 3 个方法:

  • add() 将一个元素添加到 SimpleList 的末尾。
  • size() 返回 SimpleList 中当前的元素数量。
  • clear() 完全清除 SimpleList 的内容。

清单 6 给出了参数化 SimpleList 的语法:

清单 6. 参数化 SimpleList
package com.makotojava.intro;
import java.util.ArrayList;
import java.util.List;
public class SimpleList<E> {
  private List<E> backingStore;
  public SimpleList() {
    backingStore = new ArrayList<E>();
  }
  public E add(E e) {
    if (backingStore.add(e))
    return e;
    else
    return null;
  }
  public int size() {
    return backingStore.size();
  }
  public void clear() {
    backingStore.clear();
  }
}

可使用任何 Object 子类来参数化 SimpleList。要创建并使用一个由 java.math.BigDecimal 对象组成的 SimpleList,可以这样做:

package com.makotojava.intro;
import java.math.BigDecimal;
import java.util.logging.Logger;
import org.junit.Test;
public class SimpleListTest {
  @Test
  public void testAdd() {
    Logger log = Logger.getLogger(SimpleListTest.class.getName());

    SimpleList<BigDecimal> sl = new SimpleList<>();
    sl.add(BigDecimal.ONE);
    log.info("SimpleList size is :" + sl.size());
    sl.add(BigDecimal.ZERO);
    log.info("SimpleList size is :" + sl.size());
    sl.clear();
    log.info("SimpleList size is :" + sl.size());
  }
}

而且会得到此输出:

Sep 20, 2015 10:24:33 AM com.makotojava.intro.SimpleListTest testAdd
INFO:SimpleList size is:1 Sep 20, 2015 10:24:33 AM com.makotojava.intro.SimpleListTest testAdd
INFO:SimpleList size is:2 Sep 20,
2015 10:24:33 AM com.makotojava.intro.SimpleListTest testAdd
INFO:SimpleList size is:0

参数化方法

有时,您可能不想参数化整个类,但只需要一两个方法。在本例中,创建一个 泛型方法 。考虑清单 27 中的示例,其中方法 formatArray 用于创建数组内容的字符串表示形式。

清单 7. 参数化 SimpleList
public class MyClass {
// Other possible stuff... ignore...
  public <E> String formatArray(E[] arrayToFormat) {
    StringBuilder sb = new StringBuilder();

    int index = 0;
    for (E element : arrayToFormat) {
      sb.append("Element ");
      sb.append(index++);
      sb.append(" => ");
      sb.append(element);
      sb.append('\n');
    }

    return sb.toString();
  }
// More possible stuff... ignore...
}

没有参数化 MyClass,您只将一个方法泛化,这个方法是您想要来创建适用于任何元素类型的一致字符串表示。

在实践中,您会发现自己使用参数化的类和接口的频率远高于方法,但现在您知道,如果需要,这个功能是可用的。

enum 类型

在 JDK 5 中,Java 语言新添了一种名为 enum 的数据类型。不要与 java.util.Enumeration 混淆,enum 表示一组与某个特定概念相关的常量对象,每个对象表示该集合中一个不同的常量值。将 enum 引入 Java 语言之前,必须按如下方式为一个概念(比如性别)定义一组常量值:

public class Person {
  public static final String MALE = "male";
  public static final String FEMALE = "female";
}

引用该常量值所需的代码可以像这样编写:

public void myMethod() {
  //...
  String genderMale = Person.MALE;
  //...
}

使用 enum 定义常量

使用 enum 类型让常量的定义更加正式,而且更强大。这是 Genderenum 定义:

public enum Gender {
  MALE,
  FEMALE,
  OTHER
}

此示例仅简单介绍了 enum 用途的一点皮毛。事实上,enum 非常像类,所以它们可拥有构造函数、属性和方法:

package com.makotojava.intro;

public enum Gender {
  MALE("male"),
  FEMALE("female");

  private String displayName;
  private Gender(String displayName) {
    this.displayName = displayName;
  }

  public String getDisplayName() {
    return this.displayName;
  }
}

类与 enum 的一个区别在于,enum 的构造函数必须声明为 private,而且它无法扩展(或继承自)其他 enum。但是,一个 enum 实现一个接口。

enum 实现一个接口

假设您定义了一个接口 Displayable

package com.makotojava.intro;
public interface Displayable {
  public String getDisplayName();
}

Gender enum 可像这样实现这个接口(以及生成一个友好显式名称所需的其他任何 enum):

package com.makotojava.intro;

public enum Gender implements Displayable {
  MALE("male"),
  FEMALE("female");

  private String displayName;
  private Gender(String displayName) {
    this.displayName = displayName;
  }
  @Override
  public String getDisplayName() {
    return this.displayName;
  }
}

I/O

本节将概述 java.io 包。您将学习如何使用它的一些工具来收集和操作各种不同来源的数据。

处理外部数据

在 Java 程序中使用的数据常常来自外部数据源,比如数据库、通过套接字进行的直接字节传输或文件存储。Java 语言提供了许多工具从这些来源获取信息,其中大部分工具都位于 java.io 包中。

文件

在所有 Java 应用程序可用的数据源中,文件是最常见的,常常也是最方便的。如果想在 Java 应用程序中读取一个文件,必须使用 将传入的字节解析为 Java 语言类型。

java.io.File 是一个类,它在您的文件系统上定义资源并以一种抽象的方式表示该资源。创建 File 对象很容易:

File f = new File("temp.txt");
File f2 = new File("/home/steve/testFile.txt");

File 构造函数接受它所创建的文件的名称。第一个调用在指定的目录中创建一个名为 temp.txt 的文件。第二个调用在我的 Linux 系统上的具体位置创建一个文件。您可将任何 String 传递至 File 的构造函数,只要文件名对 OS 而言是有效的,无论它引用的文件是否存在都是如此。

此代码向新创建的 File 对象询问该文件是否存在:

File f2 = new File("/home/steve/testFile.txt");
if (f2.exists()) {
  // File exists.Process it...
} else {
  // File doesn't exist.Create it...
  f2.createNewFile();
}

java.io.File 拥有其他方便的方法来:

  • 删除文件
  • 创建目录(通过将一个目录名称作为参数传递至 File 的构造函数)
  • 确定一个资源是文件、目录还是符号链接
  • 等等

Java I/O 的实际操作是写入和读取数据源,这时就需要使用流。

在 Java I/O 中使用流

可以使用流来访问文件系统上的文件。在最低限度上,流允许程序从来源接收字节或将输出发送至目标。一些流可处理所有类型的 16 位字符( ReaderWriter 类型)。其他流只能处理 8 位字节( InputStreamOutputStream 类型)。这些分层结构中包含多种风格的流,它们都可在 java.io 包中找到。在最高的抽象级别上是 字符流字节流

字节流读( InputStream 和子类)和写( OutputStream 和子类)8 位字节。换句话说,可将字节流看作一种更加原始的流类型。下面总结了两种常见的字节流及其用法:

  • FileInputStream / FileOutputStream : 从文件读取字节,将字节写入文件
  • ByteArrayInputStream / ByteArrayOutputStream :从内存型数组读取字节,将字节写入内存型数组

字符流

字符流读(Reader 和它的子类)和写(Writer 和它的子类)16 位字符。下面是一个字符流清单及其用法:

  • **StringReader** / **StringWriter**:在内存中的 String 中读取以及向其中写入字符。
  • **InputStreamReader** / **InputStreamWriter** (和子类 **FileReader** / **FileWriter**):衔接字节流和字符流。Reader 喜欢从字节流读取字节并将其转换为字符。Writer 喜欢将字符转换为字节,从而将它们放在字节流上。
  • **BufferedReader** / **BufferedWriter**:在读取或写入另一个流时缓冲数据,使读写操作更高效。

我不会介绍所有流,而是主要介绍读写文件时推荐使用的流。在大多数情况下,这些都是字符流。

从 File 读取数据

可通过多种方式从 File 读取数据。无疑最简单的方法是:

  1. 在想要从中读取数据的 File 上创建一个 InputStreamReader
  2. 调用 read() 可一次读取一个字符,直至到达文件末尾。

清单 8 是一个从 File 读取数据的示例:

清单 8. 从 File 读取数据
public List<Employee> readFromDisk(String filename) {
  final String METHOD_NAME = "readFromDisk(String filename)";
  List<Employee> ret = new ArrayList<>();
  File file = new File(filename);
  try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file))) {
    StringBuilder sb = new StringBuilder();
    int numberOfEmployees = 0;
    int character = reader.read();
    while (character != -1) {
        sb.append((char)character);
        character = reader.read();
    }
    log.info("Read file:\n" + sb.toString());
    int index = 0;
    while (index < sb.length()-1) {
      StringBuilder line = new StringBuilder();
      while ((char)sb.charAt(index) != '\n') {
        line.append(sb.charAt(index++));
      }
      StringTokenizer strtok = new StringTokenizer(line.toString(), Person.STATE_DELIMITER);
      Employee employee = new Employee();
      employee.setState(strtok);
      log.info("Read Employee:" + employee.toString());
      ret.add(employee);
      numberOfEmployees++;
      index++;
    }
    log.info("Read " + numberOfEmployees + " employees from disk.");
  } catch (FileNotFoundException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "Cannot find file " +
       file.getName() + ", message = " + e.getLocalizedMessage(), e);
  } catch (IOException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "IOException occurred,
       message = " + e.getLocalizedMessage(), e);
  }
  return ret;
}

写入 File

与从 File 读取一样,可通过多种方式将数据写入 File。我会再次介绍一下最简单的方法:

  1. 在想要写入数据的 File 上创建一个 FileOutputStream
  2. 调用 write() 写入字符序列。

清单 9 是一个将数据写入 File 的示例:

清单 9. 写入 File
public boolean saveToDisk(String filename, List<Employee> employees) {
  final String METHOD_NAME = "saveToDisk(String filename, List<Employee> employees)";

  boolean ret = false;
  File file = new File(filename);
  try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file))) {
    log.info("Writing " + employees.size() + " employees to disk (as String)...");
    for (Employee employee : employees) {
      writer.write(employee.getState()+"\n");
    }
    ret = true;
    log.info("Done.");
  } catch (FileNotFoundException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "Cannot find file " +
       file.getName() + ", message = " + e.getLocalizedMessage(), e);
  } catch (IOException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "IOException occurred,
       message = " + e.getLocalizedMessage(), e);
  }
  return ret;
}

缓冲流

逐个字符地读和写字符流并不是高效的,所以在大部分情况下,您可能希望使用缓冲的 I/O。要使用缓冲的 I/O 从文件读取数据,代码与清单 28 中所示的类似,但要将 InputStreamReader 包装在一个 BufferedReader 中,如清单 10 所示。

清单 10. 使用缓冲的 I/O 从 File 读取数据
public List<Employee> readFromDiskBuffered(String filename) {
  final String METHOD_NAME = "readFromDisk(String filename)";
  List<Employee> ret = new ArrayList<>();
  File file = new File(filename);
  try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file)))) {
    String line = reader.readLine();
    int numberOfEmployees = 0;
    while (line != null) {
      StringTokenizer strtok = new StringTokenizer(line, Person.STATE_DELIMITER);
      Employee employee = new Employee();
      employee.setState(strtok);
      log.info("Read Employee:" + employee.toString());
      ret.add(employee);
      numberOfEmployees++;
      // Read next line
      line = reader.readLine();
    }
    log.info("Read " + numberOfEmployees + " employees from disk.");
  } catch (FileNotFoundException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "Cannot find file " +
       file.getName() + ", message = " + e.getLocalizedMessage(), e);
  } catch (IOException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "IOException occurred,
       message = " + e.getLocalizedMessage(), e);
  }
  return ret;
}

使用缓冲的 I/O 将数据写入文件的过程相同:将 OutputStreamWriter 包装在一个 BufferedWriter 中,如清单 11 所示。

清单 11. 使用缓冲的 I/O 将数据写入 File
public boolean saveToDiskBuffered(String filename, List<Employee> employees) {
  final String METHOD_NAME = "saveToDisk(String filename, List<Employee> employees)";

  boolean ret = false;
  File file = new File(filename);
  try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file)))) {
    log.info("Writing " + employees.size() + " employees to disk (as String)...");
    for (Employee employee : employees) {
      writer.write(employee.getState()+"\n");
    }
    ret = true;
    log.info("Done.");
  } catch (FileNotFoundException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "Cannot find file " +
       file.getName() + ", message = " + e.getLocalizedMessage(), e);
  } catch (IOException e) {
    log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "IOException occurred,
       message = " + e.getLocalizedMessage(), e);
  }
  return ret;
}

Java 序列化

Java 序列化是 Java 平台的另一个基础库。 序列化主要用于对象持久化和对象远程传输,在这两种用例中,都需要能够获取对象状态的快照,这样在以后能重新构成它们。本节大体介绍 Java Serialization API,展示如何在程序中使用它。

什么是对象序列化?

序列化 过程中,会以一种特殊的二进制格式存储一个对象及其元数据(比如对象的类名称及其属性名称)的状态。将对象存储为此格式 — 序列化 它 — 可保留在需要时重新构成(或 反序列化 )该对象所必需的全部信息。

对象序列化有两种主要用例:

  • 对象持久化 — 将对象的状态存储在一种永久的持久性机制中,比如数据库
  • 对象远程传输 — 将对象发送至另一个计算机或系统

java.io.Serializable

实现序列化的第一步是让对象能够使用该机制。希望能够序列化的每个对象都必须实现一个名为 java.io.Serializable 的接口:

import java.io.Serializable;
public class Person implements Serializable {
  // etc...
}

在此示例中,Serializable 接口将 Person 类(和 Person 的每个子类的对象)向运行时标记为 serializable

如果 Java 运行时尝试序列化您的对象,无法序列化的对象的每个属性会导致它抛出一个 NotSerializableException。可以使用 transient 关键字管理此行为,告诉运行时不要尝试序列化一些属性。在这种情况下,您应该负责确保恢复这些属性(在必要时),以便您的对象能正常运行。

序列化一个对象

现在我们通过一个示例,尝试将刚学到的 Java I/O 知识与现在学习的序列化知识结合起来。

假设您创建并填充一个包含 Employee 对象的 List,然后希望将该 List 序列化为一个 OutputStream,在本例中是序列化为一个文件。 该过程如清单 12 所示。

清单 12. 序列化一个对象
public class HumanResourcesApplication {
  private static final Logger log = Logger.getLogger(HumanResourcesApplication.class.getName());
  private static final String SOURCE_CLASS = HumanResourcesApplication.class.getName();

  public static List<Employee> createEmployees() {
    List<Employee> ret = new ArrayList<Employee>();
    Employee e = new Employee("Jon Smith", 45, 175, 75, "BLUE", Gender.MALE,
       "123-45-9999", "0001", BigDecimal.valueOf(100000.0));
    ret.add(e);
    //
    e = new Employee("Jon Jones", 40, 185, 85, "BROWN", Gender.MALE, "223-45-9999",
       "0002", BigDecimal.valueOf(110000.0));
    ret.add(e);
    //
    e = new Employee("Mary Smith", 35, 155, 55, "GREEN", Gender.FEMALE, "323-45-9999",
       "0003", BigDecimal.valueOf(120000.0));
    ret.add(e);
    //
    e = new Employee("Chris Johnson", 38, 165, 65, "HAZEL", Gender.UNKNOWN,
       "423-45-9999", "0004", BigDecimal.valueOf(90000.0));
    ret.add(e);
    // Return list of Employees
    return ret;
  }

  public boolean serializeToDisk(String filename, List<Employee> employees) {
    final String METHOD_NAME = "serializeToDisk(String filename, List<Employee> employees)";

    boolean ret = false;// default: failed
    File file = new File(filename);
    try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream(file))) {
      log.info("Writing " + employees.size() + " employees to disk (using Serializable)...");
      outputStream.writeObject(employees);
      ret = true;
      log.info("Done.");
    } catch (IOException e) {
      log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "Cannot find file " +
       file.getName() + ", message = " + e.getLocalizedMessage(), e);
    }
    return ret;
  }

第一步是创建这些对象,在 createEmployees() 中使用 Employee 的特殊化构造函数和一些属性值来完成该工作。接下来创建一个 OutputStream (在本例中为 FileOutputStream),然后在该流上调用 writeObject()writeObject() 是一个方法,它使用 Java 序列化将一个对象序列化为流。

在此示例中,您将 List 对象(以及它包含的 Employee 对象)存储在一个文件中,但同样的技术可用于任何类型的序列化。

要成功运行清单 12 中的代码,您可以使用 JUnit 测试,如下所示:

public class HumanResourcesApplicationTest {

  private HumanResourcesApplication classUnderTest;
  private List<Employee> testData;

  @Before
  public void setUp() {
    classUnderTest = new HumanResourcesApplication();
    testData = HumanResourcesApplication.createEmployees();
  }
  @Test
  public void testSerializeToDisk() {
    String filename = "employees-Junit-" + System.currentTimeMillis() + ".ser";
    boolean status = classUnderTest.serializeToDisk(filename, testData);
    assertTrue(status);
  }
}

反序列化对象

序列化对象的唯一目的就是为了能够重新构成或反序列化它。清单 13 读取刚序列化的文件并对其内容反序列化,然后恢复 Employee 对象的 List 的状态。

清单 13. 反序列化对象
public class HumanResourcesApplication {

  private static final Logger log = Logger.getLogger(HumanResourcesApplication.class.getName());
  private static final String SOURCE_CLASS = HumanResourcesApplication.class.getName();

  @SuppressWarnings("unchecked")
  public List<Employee> deserializeFromDisk(String filename) {
    final String METHOD_NAME = "deserializeFromDisk(String filename)";

    List<Employee> ret = new ArrayList<>();
    File file = new File(filename);
    int numberOfEmployees = 0;
    try (ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(file))) {
      List<Employee> employees = (List<Employee>)inputStream.readObject();
      log.info("Deserialized List says it contains " + employees.size() +
         " objects...");
      for (Employee employee : employees) {
        log.info("Read Employee:" + employee.toString());
        numberOfEmployees++;
      }
      ret = employees;
      log.info("Read " + numberOfEmployees + " employees from disk.");
    } catch (FileNotFoundException e) {
      log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "Cannot find file " +
         file.getName() + ", message = " + e.getLocalizedMessage(), e);
    } catch (IOException e) {
      log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "IOException occurred,
       message = " + e.getLocalizedMessage(), e);
    } catch (ClassNotFoundException e) {
      log.logp(Level.SEVERE, SOURCE_CLASS, METHOD_NAME, "ClassNotFoundException,
         message = " + e.getLocalizedMessage(), e);
    }
    return ret;
  }

}

同样地,要成功运行清单 13 中的代码,可以使用一个类似这样的 JUnit 测试:

public class HumanResourcesApplicationTest {

  private HumanResourcesApplication classUnderTest;

  private List<Employee> testData;

  @Before
  public void setUp() {
    classUnderTest = new HumanResourcesApplication();
  }

  @Test
  public void testDeserializeFromDisk() {
    String filename = "employees-Junit-" + System.currentTimeMillis() + ".ser";
    int expectedNumberOfObjects = testData.size();
    classUnderTest.serializeToDisk(filename, testData);
    List<Employee> employees = classUnderTest.deserializeFromDisk(filename);
    assertEquals(expectedNumberOfObjects, employees.size());
  }

}

对于大多数应用程序的用途,将对象标记为 serializable 是执行序列化工作时唯一需要担心的问题。需要明确地序列化和反序列化对象时,可使用清单 12清单 13 中所示的技术。但是,随着应用程序对象不断演变,以及在它们之中添加和删除属性,序列化会变得更加复杂。

serialVersionUID

回想中间件和远程对象通信的发展初期,开发人员主要负责控制其对象的”连接格式”,随着技术开始演变,这引发了大量头疼的问题。

假设您向一个对象添加了一个属性,重新编译了它,然后将该代码重新分发到一个应用程序集群中的每个计算机上。该对象存储在一个具有某种代码版本的计算机中,但其他可能具有不同代码版本的计算机访问该对象。这些计算机尝试反序列化该对象时,常常会发生糟糕的事情。

Java 序列化元数据 — 所包含的二进制序列化格式的信息 — 很复杂,而且解决了困扰早期中间件开发人员的许多问题。但它并非能解决所有问题。

Java 序列化使用一个名为 serialVersionUID 的特性来帮助您处理序列化场景中的不同对象版本问题。不需要在对象上声明此特性;默认情况下,Java 平台会使用一种算法并根据类的属性、它的类名称以及在庞大的本地集群中的位置来计算值。在大多数情况下,该算法都能正常运行。但是,如果添加或删除一个属性,这个动态生成的值就会发生变化,而且 Java 运行时会抛出 InvalidClassException

要想避免这种情况,可养成明确声明 serialVersionUID 的习惯:

import java.io.Serializable;
  public class Person implements Serializable {
  private static final long serialVersionUID = 20100515;
  // etc...
  }

我建议您为 serialVersionUID 版本号使用某种模式(我在前面的示例中使用了当前日期)。而且应将 serialVersionUID 声明为 private static finallong 类型。

您可能想知道何时应更改此特性。简单的答案是,只要对类执行了不兼容的更改(这通常意味着删除了一个属性),就应该更改它。如果在一台计算机上拥有该对象的一个已删除了某个属性的版本,而且将该对象远程传输至一台计算机,其中包含的对象版本需要该属性,此时就会发生怪异的事情。这时就可以使用 Java 平台的内置 serialVersionUID 进行检查。

作为一条经验规则,任何时候添加或删除一个类特性(也就是属性和方法),都需要更改它的 serialVersionUID。在连接的另一端获得一个 InvalidClassException,比由不兼容的类更改导致应用程序错误要更好一些。

结束语

Java™ 编程入门 系列介绍了 Java 语言的大量知识,但该语言博大精深。一部教程无法涵盖所有内容。

随着您继续学习 Java 语言和平台,您可能希望进一步研究正则表达式、泛型和 Java 序列化等主题。最终,您可能还想探索这部介绍性教程中未涵盖的主题,如并发性和持久化。

本文翻译自:Java constructs for real-world applications, Part 2(2020-09-18)