IO系统

对于所有的编程语言, 其实IO都是很重要的部分. 前边的泛型和反射可以用到的时候再看, 但是IO的部分和Java的基础部分, 都是经常要使用的工具, 必须熟练掌握.

不过Java的IO类其实很多, 外加经过了多年发展, 因此IO类的整体架构看上去可能有些混乱, 需要一点一点来摸清楚.

  1. File类
  2. 输入和输出综述
  3. InputStream
  4. OutputStream
  5. FilterInputStream 从 InputStream 中读取数据
  6. FilterOutputStream
  7. Reader 和 Writer
  8. 常用组合方式
  9. RandomAccessFile

File类

首先要搞清楚的是, File类并不是像C语言的File宏一样代表一个文件. File类实际上既可以代表一个文件, 又可以代表一个目录之下一组文件的名称. 实际上File代表的是一个路径, 这个路径可以是一个具体的文件, 也可以是一个目录名.

常用的应用如下, 列出一个目录下的文件, 以及根据需要过滤结果:

package thinkinginjava.learn.chapter18;

import java.io.File;
import java.io.FilenameFilter;
import java.util.Arrays;
import java.util.regex.Pattern;

public class DirList {

    public static void main(String[] args) {

        //使用构造器传入路径名, . 表示当前路径
        File path = new File(".");
        //声明一个字符串数组用来存放列出来的文件名
        String[] list;

        //如果目录名没有输入, 装入当前的目录下文件名
        if (args.length == 0) {
            list = path.list();
        }

        else {
            //list接受一个实现了FilenameFilter接口的对象, 会调用其中的accept方法判断list中每一个对象, 只过滤满足条件的内容.
            list = path.list(new DirFilter(args[0]));
        }

        if (list == null) {
            return;
        }
        Arrays.sort(list, String.CASE_INSENSITIVE_ORDER);

        for (String item : list) {
            System.out.println(item);
        }
    }

}

//过滤文件名的类, 根据正则过滤
class DirFilter implements FilenameFilter {
    private Pattern pattern;

    DirFilter(String regex) {
        this.pattern = Pattern.compile(regex);
    }

    @Override
    public boolean accept(File dir, String name) {
        return pattern.matcher(name).matches();
    }
}

还可以采取匿名内部类改进:

public class DirList2 {

    public static FilenameFilter filter(final String regex) {
        //使用静态方法内部的匿名内部类, 来返回一个filter, 传入final的regex即可得到一个对应的filter
        return new FilenameFilter() {
            private Pattern pattern = Pattern.compile(regex);

            @Override
            public boolean accept(File dir, String name) {
                return pattern.matcher(name).matches();
            }
        };
    }

    public static void main(String[] args) {

        //使用构造器传入路径名, . 表示当前路径
        File path = new File(".");
        //声明一个字符串数组用来存放列出来的文件名
        String[] list;

        //如果目录名没有输入, 装入当前的目录下文件名
        if (args.length == 0) {
            list = path.list();
        }

        else {
            //list接受一个实现了FilenameFilter接口的对象, 会调用其中的accept方法判断list中每一个对象, 只过滤满足条件的内容.
            list = path.list(new DirFilter(args[0]));
        }

        if (list == null) {
            return;
        }
        Arrays.sort(list, String.CASE_INSENSITIVE_ORDER);

        for (String item : list) {
            System.out.println(item);
        }
    }

}

进一步改进是直接将匿名内部类放入到list()方法中作为参数. 就不再写了.

有了一个路径表示的File对象, 就可以通过这个对象操作文件, 这也是很多程序语言里都具备的与OS相关的操作库.

File有一系列Get方法可以用来获取路径相关的信息以及权限. 其中.listFiles()可以直接获取一个File数组, 里边每一个元素都是对应目录下边的文件或者目录的File对象

  1. .getAbsolutePath()
  2. .canRead()
  3. .canWrite()
  4. .getName()
  5. .getParent()
  6. .getPath()
  7. .length()

其他就可以看文档了, 类似于很多编程语言的相同功能的库, 当然底层都是调用操作系统的功能了.

输入和输出综述

从整体来讲, IO库最大的分类是输入和输出两大类. 使用流这个抽象.

输入和输出的对象指的是程序, 输入指的是从程序外部读取数据, 输出指的是向程序外部输出数据.

所有的从InputStream和Reader派生来的类都有read()基本方法, 用于读单个字节或者字节数组.

所有的从OutputStream和Writer派生来的类都有write()基本方法, 用于写单个字节或者字节数组.

但一般不会使用这两个基本方法, 而是叠合多个对象来提供想要的功能. 最关键要理解, 需要一个流, 可能要创建多个对象, 这是最令人迷惑的一点.

InputStream

Java 1.0的时候, 所有输入类都要从InputStream继承. 所有输出类都要从OutputStream继承.

但是不直接使用这个InputStream, 针对如下的输入, 每一种都有对应的子类:

  1. 字节数组 – ByteArrayInputStream
  2. String对象 – StringBufferInputStream
  3. 文件 – FileInputStream
  4. 管道 – PipedInputStream
  5. 其他流组成的序列合并成一个流 – SequenceInputStream
  6. 其他数据源,比如网络

还有一个FilterInputStream, 也属于一种InputStream, 是一个抽象类, 为装饰器类提供基类, 即为其他的InputStream类提供功能.

OutputStream

输出的话主要有两种, 输出字节或者输出字符:

  1. 字节数组 – ByteArrayOutputStream
  2. 文件 – FileOutputStream
  3. 管道 – PipedOutputStream

此外也有FilterOutputStream.

FilterInputStream 从 InputStream 中读取数据

其实FilterInputStream是装饰器类, 控制InputStream的行为, 由于这是一个抽象类, 所以还有很多具体的类,如下:

  1. DataInputStream – 读取基本类型和String对象, 与 DataOutputStream搭配使用
  2. BufferedInputStream – 使用缓冲区的读取
  3. LineNumberInputStream – 跟踪输入流的行号, 可以调用getLineNumber()等方法.
  4. PushedbackInputStream – 用不到.

FilterOutputStream

  1. DataOutputStream – 与DataInputStream搭配使用, 输入到流中, 方便其他流读取
  2. PrintStream – 直接输入到标准输出, 不会产生流. 有两个重要方法print()和println()
  3. BufferedOutputStream – 采用缓冲输出, 不是每次都实际写入流. 调用.flush()才会清空缓冲区并实际写入.

这里以上的部分, 都是Java 1.0时候的输入输出类库, 在之后会有变化.

Reader 和 Writer

Java 1.1时代新添加了Reader 和 Writer. 实际上1.0时代的类现在主要面向字节了. Reader和Writer则提供了兼容Unicode和面向字符的I/O. 老I/O只能面向8位字节流.

这意味着从Java 1.1开始, 实际上你可以在面向字节和面向字符中做选择, 而不用费力的用同一个库去读写.

明智的做法是尽量尝试Reader和Writer.不行再改用字节.

还有一批对应关系, 实际上的本质就是Reader 和 Writer也和之前的类在使用的时候要套壳是一样的, 外部是装饰Reader或者Writer, 而内部是基础的Reader或者Writer.

常用组合方式

虽然类有很多, 但其实也就用到其中的几种组合. 比如从二进制文件中读字节, 从文本文件中读字符.

按行读取字符

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class BufferedInputFile {

    public static String read(String filename) throws IOException {
        //FileReader适配FileInputStream, 而FileInputStream是基础的文件读取类, BufferedReader是装饰
        BufferedReader in = new BufferedReader(new FileReader(filename));
        String s;
        StringBuilder sb = new StringBuilder();
        //readline方法会去掉换行符, 所以要补上换行符
        while ((s = in.readLine()) != null) {
            sb.append(s).append("\n");
        }
        in.close();
        return sb.toString();
    }

    public static void main(String[] args) throws IOException {
        String filename = "D:\\Coding\\Java-Exercises\\src\\thinkinginjava\\learn\\chapter18\\DirList2.java";
        System.out.println(read(filename));
    }
}

有了从文本文件中按行读取字符, 就可以随便对读入的东西进行操作了.

按字符读取文件

public class MemoryInput {
    public static void main(String[] args) throws IOException {
        StringReader in = new StringReader(BufferedInputFile.read("D:\\test.txt"));

        int c;

        while ((c = in.read()) != -1) {
            System.out.println((char) c);
        }
    }
}

按字节读取文件

按字节就不可以使用Reader类, 必须使用Stream类.

import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;

public class FormattedMemoryInput {

    public static void main(String[] args) {
        try{
            //套了两层壳的流. 可见套壳的最内部,都是文件输入输出对象.
            //如果是Writer Reader, 只需要套一个壳, 如果是Stream, 则必须套两个壳, 内层是InputStream类型, 外层是FilterInputStream这个装饰类的具体子类.
            //现在终于明白了, 就是要把装饰类FilterInputStream套在基类InputStream外边, 终于明白了之前说的意思
            DataInputStream in = new DataInputStream(new ByteArrayInputStream(BufferedInputFile.read("D:\\test.txt").getBytes()));
            while (true) {
                System.out.println((char) in.readByte());
            }

            //如果不使用上边的方法, 也可以用available来判断还剩余多少字符可读, 依然是一个一个读取
            while (in.available() != 0) {
                System.out.println(in.readByte());
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

从文本文件读出, 然后向文件输出字符

既然是字符, 那就是Writer和Reader类, 读取文件的内层FileInputStream对应的是FileReader, 外层套壳用BufferedReader. 写入的时候内层是FileWriter, 外层套壳可以用PrintWriter也可以用BufferedWriter:

public class BasicFileOutput {

    static String file = "out.out";

    public static void main(String[] args) throws IOException {
        //采取从文件中读字符的方式
        BufferedReader in = new BufferedReader(new FileReader("D:\\test.txt"));

        //实际上应该使用BufferedWriter去写, 不过PrintWriter进行了装饰, 可以更方便的写
        //PrintWriter有print系列方法,比较方便
        PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(file)));

//        BufferedWriter out = new BufferedWriter(new FileWriter(file));
        int lineCount = 1;
        String s;
        while ((s = in.readLine()) != null) {
//            out.write(lineCount++ + ": " + s + "\n");
            out.println(lineCount++ + ": " + s);
        }
        out.close();
        System.out.println(BufferedInputFile.read(file));
    }
}

PrintWriter还可以直接用一个文件名来初始化, 直接写入, 比较方便:

public class BasicFileOutputShort {

    static String file = "out.out";

    public static void main(String[] args) throws IOException {
        BufferedReader in = new BufferedReader(new FileReader("D:\\test.txt"));
        PrintWriter out = new PrintWriter(file);
        int lineCount = 1;
        String s;
        while ((s = in.readLine()) != null) {
            out.println(lineCount++ + ": " + s);
        }
        out.close();
        System.out.println(BufferedInputFile.read(file));

    }
}

如上边的红色部分所示, 可以大大简化套壳创建流的过程.

这内部已经使用了带有缓存的输出, 不过除了PrintWriter之外的IO都没有这个简化创建文件绑定流的功能.

字节写和读文件

import java.io.*;

public class StoringData {

    public static void main(String[] args) throws IOException {

        //写入字节(二进制文件)
        DataOutputStream out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream("target.txt")));

        //write系列方法,需要指定数据类型, 其中的UTF是UTF-8,但是前边加入了Java自己的编码
        out.writeDouble(4.04);
        out.writeInt(10000);
        out.writeUTF("saner");
        out.writeDouble(1.4134);
        out.writeUTF("Square root of 2");
        out.close();

        DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream("data.io")));

        //用相同的顺序, 就可以将数据读出, 这也可以用来序列化
        System.out.println(in.readDouble());
        System.out.println(in.readInt());
        System.out.println(in.readUTF());
        System.out.println(in.readDouble());
        System.out.println(in.readUTF());
    }
}

这实际上是写入二进制文件, 就可以用一个二进制文件来传输数据. 比如一个特定的头部加上其他内容. 这个好处是只要其他地方也是Java, 可以读出来, 就算将数据和字符串混合起来也没有问题.

RandomAccessFile

这是一个独立的类, 与Java IO库并没有关系. 适合于已知大小的文件, 而不是一个流. 可以在文件中使用seek()来移动位置. 其中一些主要方法如下:

  1. .getFilePointer(), 获取位置
  2. .seek(), 移动位置
  3. .length(), 判断文件的尺寸
  4. 构造器需要第二个参数, 指明r 还是 rw ,即读写方式. 不支持只写文件.

读写这种文件, 一般是采取固定的格式, 这样可以快捷的移动位置来读取. 例子如下:

import java.io.IOException;
import java.io.RandomAccessFile;

public class UsingRandomAccessFile {

    static String file = "rtest.data";

    static void display() throws IOException {
        RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");

        for (int i = 0; i < 7; i++) {
            System.out.println("Value " + i + ": " + randomAccessFile.readDouble());
        }

        System.out.println(randomAccessFile.readUTF());
        randomAccessFile.close();
    }

    public static void main(String[] args) throws IOException {
        RandomAccessFile rf = new RandomAccessFile(file, "rw");

        //写7个double和一个字符串
        for (int i = 0; i < 7; i++) {
            rf.writeDouble(i * 1.414);
        }
        rf.writeUTF("This is end of file");
        rf.close();

        display();

        //一个double长度是64位, 也就是8字节, 初始是0
        //已经关闭了就不能再使用, 必须重新打开
        RandomAccessFile rf2 = new RandomAccessFile(file, "rw");
        //0的位置是第一个数字, 很显然这里就应该是第6个数字
        rf2.seek(40);
        //修改第6个数字
        rf2.writeDouble(99999);
        rf2.close();

        //再打印就可以看到第6个数字也就是Value 5发生了改变
        display();
    }
}

这种适合已经知道了结构的文件, 写入的数据要和原来位置上的数据一致, 否则会破坏数据.

管道流用于任务间的通信, 还是要学啊, 没这么简单.

IO这里的关键就是要搞清楚装饰类和被装饰类, 改天我在公众号写一篇文章, 详细总结一下Java 的IO, 把套壳的顺序也说一下.