10.1 Java处理I/O的经典方式

File 类是以前 Java 处理文件 I/O 的基础。这个抽象既能表示文件,也能表示目录,不过有时使用起来有些麻烦,写出的代码如下所示:

  1. // 创建一个文件对象,表示用户的家目录
  2. File homedir = new File(System.getProperty("user.home"));
  3. // 创建一个对象,表示配置文件
  4. // (家目录中应该存在这个文件)
  5. File f = new File(homedir, "app.conf");
  6. // 检查文件是否存在,是否真是文件,以及是否可读
  7. if (f.exists() && f.isFile() && f.canRead()) {
  8. // 创建一个文件对象,表示新配置目录
  9. File configdir = new File(f, ".configdir");
  10. // 然后创建这个目录
  11. configdir.mkdir();
  12. // 最后,把配置文件移到新位置
  13. f.renameTo(new File(configdir, ".config"));
  14. }

上述代码展现了 File 类使用灵活的一面,但也演示了这种抽象带来的一些问题。一般情况下,需要调用很多方法查询 File 对象才能判断这个对象到底表示的是什么,以及具有什么能力。

10.1.1 文件

File 类中有相当多的方法,但根本没有直接提供一些基本功能(尤其是无法读取文件的内容)。

下述代码简要总结了 File 类中的方法:

  1. // 权限管理
  2. boolean canX = f.canExecute();
  3. boolean canR = f.canRead();
  4. boolean canW = f.canWrite();
  5. boolean ok;
  6. ok = f.setReadOnly();
  7. ok = f.setExecutable(true);
  8. ok = f.setReadable(true);
  9. ok = f.setWritable(false);
  10. // 使用不同的方式表示文件名
  11. File absF = f.getAbsoluteFile();
  12. File canF = f.getCanonicalFile();
  13. String absName = f.getAbsolutePath();
  14. String canName = f.getCanonicalPath();
  15. String name = f.getName();
  16. String pName = getParent();
  17. URI fileURI = f.toURI(); // 创建文件路径的URI形式
  18. // 文件的元数据
  19. boolean exists = f.exists();
  20. boolean isAbs = f.isAbsolute();
  21. boolean isDir = f.isDirectory();
  22. boolean isFile = f.isFile();
  23. boolean isHidden = f.isHidden();
  24. long modTime = f.lastModified(); // 距Epoch时间的毫秒数
  25. boolean updateOK = f.setLastModified(updateTime); // 毫秒
  26. long fileLen = f.length();
  27. // 文件管理操作
  28. boolean renamed = f.renameTo(destFile);
  29. boolean deleted = f.delete();
  30. // 创建文件不会覆盖现有文件
  31. boolean createdOK = f.createNewFile();
  32. // 处理临时文件
  33. File tmp = File.createTempFile("my-tmp", ".tmp");
  34. tmp.deleteOnExit();
  35. // 处理目录
  36. boolean createdDir = dir.mkdir();
  37. String[] fileNames = dir.list();
  38. File[] files = dir.listFiles();

File 类中还有一些方法不完全符合这种抽象。其中多数方法都要查询文件系统(例如,查询可用空间):

  1. long free, total, usable;
  2. free = f.getFreeSpace();
  3. total = f.getTotalSpace();
  4. usable = f.getUsableSpace();
  5. File[] roots = File.listRoots(); // 所有可用的文件系统根目录

10.1.2 流

I/O 流抽象(不要跟 Java 8 集合 API 使用的流搞混了)出现在 Java 1.0 中,用于处理硬盘或其他源发出的连续字节流。

这个 API 的核心是一对抽象类,InputStreamOutputStream。这两个类使用广泛,事实上,“标准”输入和输出流(System.inSystem.out)就是这种流。标准输入和输出流是 System 类的公开静态字段,在最简单的程序中也能用到:

  1. System.out.println("Hello World!");

流的某些特定的子类,例如 FileInputStreamFileOutputStream,可以操作文件中单独的字节。例如,下述代码用于统计文件中 ASCII 97(小写的 a)出现的次数:

  1. try (InputStream is = new FileInputStream("/Users/ben/cluster.txt")) {
  2. byte[] buf = new byte[4096];
  3. int len, count = 0;
  4. while ((len = is.read(buf)) > 0) {
  5. for (int i=0; i<len; i++)
  6. if (buf[i] == 97) count++;
  7. }
  8. System.out.println("'a's seen: "+ count);
  9. } catch (IOException e) {
  10. e.printStackTrace();
  11. }

使用这种方式处理硬盘中的数据缺乏灵活性,因为多数开发者习惯以字符而不是字节的方式思考问题。因此,这种流经常和高层的 ReaderWriter 类结合在一起使用。ReaderWriter 类处理的是字符流,而不是 InputStreamOutputStream 及其子类提供的低层字节流。

10.1.3 ReaderWriter

把抽象从字节提升到字符后,开发者就更熟悉所面对的 API 了,而且这样也能规避很多由字符编码和 Unicode 等引起的问题。

ReaderWriter 类架构在字节流相关的类之上,无需再处理低层 I/O 流。这两个类有几个子类,往往都两两结合在一起使用,例如:

  • FileReader

  • BufferedReader

  • InputStreamReader

  • FileWriter

  • PrintWriter

  • BufferedWriter

若想读取一个文件中的所有行,并把这些行打印出来,可以在 FileReader 对象的基础上使用 BufferedReader 对象,如下述代码所示:

  1. try (BufferedReader in =
  2. new BufferedReader(new FileReader(filename))) {
  3. String line;
  4. while((line = in.readLine()) != null) {
  5. System.out.println(line);
  6. }
  7. } catch (IOException e) {
  8. // 这处理FileNotFoundException等异常
  9. }

如果想从终端读取行,而不是文件,一般会在 System.in 对象上使用 InputStreamReader 对象。我们来看个例子,在这个示例中我们想从终端读取行,但特殊对待以特殊字符开头的行——这种行是要处理的命令(“元”),而不是普通文本。很多聊天程序,包括 IRC,都需要这种功能。这里,我们要借助第 9 章介绍的正则表达式:

  1. Pattern SHELL_META_START = Pattern.compile("^#(\\w+)\\s*(\\w+)?");
  2. try (BufferedReader console =
  3. new BufferedReader(new InputStreamReader(System.in))) {
  4. String line;
  5. READ: while((line = console.readLine()) != null) {
  6. // 检查特殊的命令
  7. Matcher m = SHELL_META_START.matcher(line);
  8. if (m.find()) {
  9. String metaName = m.group(1);
  10. String arg = m.group(2);
  11. doMeta(metaName, arg);
  12. continue READ;
  13. }
  14. System.out.println(line);
  15. }
  16. } catch (IOException e) {
  17. // 这里处理FileNotFoundException等异常
  18. }

若想把文本输出到文件中,可以使用如下代码:

  1. File f = new File(System.getProperty("user.home")
  2. + File.separator + ".bashrc");
  3. try (PrintWriter out
  4. = new PrintWriter(new BufferedWriter(new FileWriter(f)))) {
  5. out.println("## Automatically generated config file. DO NOT EDIT");
  6. } catch (IOException iox) {
  7. // 处理异常
  8. }

Java 处理 I/O 的旧风格中有些功能偶尔也有用。例如,处理文本文件时,FilterInputStream 类往往非常有用。对于想使用类似于经典“管道”I/O 方式通信的线程来说,Java 提供了 PipedInputStreamPipedReader 类,以及对应的写入器。

到目前为止,本章多次用到了一种语言特性——“处理资源的try 语句”(try-with-resources,TWR)。这种语句的句法在 2.5.18 节简单介绍过,但要结合 I/O 等操作才能充分发挥潜能,而且还给旧 I/O 风格带来了新生。

10.1.4 再次介绍TWR

为了充分发挥 Java 的 I/O 能力,一定要理解如何以及何时使用 TWR。何时使用很好确定,只要可以用就用。

在 TWR 出现之前,必须手动关闭资源,而且处理资源之间复杂交互的代码可能有缺陷,无法关闭资源,从而导致资源泄露。

事实上,根据甲骨文工程师的估计,在 JDK 6 的初始版本中,处理资源的代码有 60% 都不正确。因此,既然连平台的作者都无法完全正确地手动处理资源,那么所有新代码显然都应该使用 TWR。

实现 TWR 的关键是一个新接口——AutoCloseable。这个新接口(在 Java 7 中出现)是 Closeable 的直接超接口,表示资源必须自动关闭。为此,编译器会插入特殊的异常处理代码。

在 TWR 的资源子句中,只能声明实现了 AutoCloseable 接口的对象,而且数量不限:

  1. try (BufferedReader in = new BufferedReader(
  2. new FileReader("profile"));
  3. PrintWriter out = new PrintWriter(
  4. new BufferedWriter(
  5. new FileWriter("profile.bak")))) {
  6. String line;
  7. while((line = in.readLine()) != null) {
  8. out.println(line);
  9. }
  10. } catch (IOException e) {
  11. // 这里处理FileNotFoundException等异常
  12. }

这样写,资源的作用域就自动放入 try 块中,各个资源(不管是可读的还是可写的)会按照正确的顺序自动关闭,而且编译器插入的异常处理代码会考虑到资源之间的相互依赖关系。

TWR 的作用大致和 C# 的 using 关键字类似,开发者可以把 TWR 看成“正确的终结方式”。6.4 节说过,新代码绝对不能直接使用终结机制,而一定要使用 TWR。旧代码应该根据情况尽早重构,换用 TWR。

10.1.5 I/O经典处理方式的问题

即便添加了受欢迎的 TWR,File 及相关的类还是有一些问题,就算执行标准的 I/O 操作也不理想,无法广泛使用。例如:

  • 缺少处理常见操作的方法;

  • 在不同的平台中不能使用一致的方式处理文件名;

  • 没有统一的文件属性模型(例如,读写模型);

  • 难以遍历未知的目录结构;

  • 没有平台或操作系统专用的特性;

  • 不支持使用非阻塞方式处理文件系统。

为了改善这些缺点,Java 的 I/O API 在过去的几个主版本中一直在改进。直到 Java 7,处理 I/O 才真正变得简单而高效。