加载中...

Java-IO流


Java IO流

Java IO 基础知识

1. 概述

  • IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。
  • IO流在Java中分为输入流输出流,而根据数据的处理方式又分为字节流字符流
    Java IO流的40多个类都是从如下4个抽象类基类中派生出来的:
    • InputStream/Reader:所有输入流的基类,前者是字节输入流,后者是字符输入流。
    • OutputStream/Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。

注意:

  • 字节流可以处理所有类型的数据,包括文本和二进制,而字符流更适合处理文本数据。
  • 字节流在处理文本数据时需要考虑字符编码的问题,而字符流会根据指定的字符编码进行正确解析,因此更适合处理中文文本。

关系类图

  • InputStream
  • OutputStream
  • Reader
  • Writer

2. 字节流

2.1 InputStream(字节输入流)

2.1.1 概述

InputStream 用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream 抽象类是所有字节输入流的父类。

2.1.2 常用方法
方法 作用
read() 返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 -1 ,表示文件结束。
read(byte b[ ]) 从输入流中读取一些字节存储到数组 b 中。如果数组 b 的长度为零,则不读取。如果没有可用字节读取,返回 -1。如果有可用字节读取,则最多读取的字节数最多等于 b.length , 返回读取的字节数。这个方法等价于 read(b, 0, b.length)。
read(byte b[], int off, int len) 在read(byte b[ ]) 方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
skip(long n) 忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
available() 返回输入流中可以读取的字节数
close() 关闭输入流释放相关的系统资源。
从 Java 9 开始,InputStream 新增加了多个实用的方法:
readAllBytes() 读取输入流中的所有字节,返回字节数组。
readNBytes(byte[] b, int off, int len) 阻塞直到读取 len 个字节。
transferTo(OutputStream out) 将所有字节从一个输入流传递到一个输出流。
2.1.3 实现类
  • FileInputStream
    • FileInputStream 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。
    public static void main(String[] args) throws IOException {
        InputStream fis = new FileInputStream("input.txt");
        System.out.println("Number of remaining bytes:"+fis.available());
        int content;
        long skip = fis.skip(2);
        System.out.println("The actual number of skipped bytes:"+skip);
        System.out.println("The content read from file:");
        while ((content = fis.read()) != -1) {
            System.out.print((char) content);
        }
        fis.close();
    }
    /*
    * input.txt文件内容:LLJavaGuide
    * 输出:
    * Number of remaining bytes:11
    * The actual number of skipped bytes:2
    * The content read from file:JavaGuide
    * */

    使用技巧:一般我们是不会直接单独使用 FileInputStream ,通常会配合 BufferedInputStream(字节缓冲输入流,下面会讲到)来使用。

    //我们可以通过readAllBytes()读取输入流中所有字节并将其直接赋值给一个String对象
    public static void main(String[] args) throws IOException {
        //创建一个BufferedInputStream 对象
        BufferedInputStream bis = new BufferedInputStream(new FileInputStream("input.txt"));
        //读取文件的内容并复制到String对象中
        String str = new String(bis.readAllBytes());
        System.out.println(str);
    }
  • DataInputStream(数据流)
    • DataInputStream 用于读取指定类型数据,不能单独使用,必须结合 FileInputStream
    public static void main(String[] args) throws IOException {
        FileInputStream fis = new FileInputStream("input.txt");
        //必须将fis作为构造参数才能使用
        DataInputStream dis = new DataInputStream(fis);
        //可以读取任意具体的类型数据
        dis.readBoolean();//从文件中读取一个 boolean 类型的字节数据。
        dis.readInt();//从文件中读取一个 int 类型的字节数据。
        dis.readUTF();//从文件中读取一个 UTF 字符串数据。
        dis.readByte();//从文件中读取一个byte类型的字节数据
        //....
    }

    注意:DataOutputStream写的文件,只能使用DataInputStream去读。并且读的时候你需要提前知道写入的顺序。读的顺序需要和写的顺序一致。才可以正常取出数据。[无论是不是DataOutputStream写的文件,用DataInputStream去读,都需要保证读取顺序与写入顺序一致]

  • ObjectInputStream(序列化)
    • ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流 (序列化)。
     public static void main(String[] args) throws IOException {
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("users"));
        //Object obj = ois.readObject();
        //System.out.println(obj instanceof List);//true
        List<User> userList = (List<User>)ois.readObject();
        for(User user : userList){
            System.out.println(user);
        }
        ois.close();
    }

    注意:用于序列化和反序列化的类必须实现 Serializable 接口,生成序列化版本号。对象中如果有属性不想被序列化,使用 transient 修饰。

2.2 OutputStream(字节输出流)

2.2.1 概述

OutputStream 用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream 抽象类是所有字节输出流的父类。

2.1.2 常用方法
方法 作用
write(int b) 将指定字节b写入到输出流中。
read(byte b[ ]) 将数组b写入到输出流,等价于 read(b, 0, b.length)。
write(byte b[], int off, int len) 在write(byte b[])方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字节数)。
flush() 刷新此输出流并强制写出所有缓冲的字节[多用在带有缓冲流的实现类中]
close() 关闭输出流释放相关的系统资源。
2.2.3 实现类
  • FileOutputStream
    • FileOutputStream 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。
    public static void main(String[] args) throws IOException {
        FileOutputStream fis = new FileOutputStream("output.txt");//若文件不存在,会自动创建。第二个参数(true/false)表示是否追加。[不填默认覆盖]
        byte[] bytes = "JavaGuide".getBytes();
        fis.write(bytes);
        fis.close();
    }

    使用技巧:类似于 FileInputStream,FileOutputStream 通常也会配合 BufferedOutputStream(字节缓冲输出流,下面会讲到)来使用

     public static void main(String[] args) throws IOException {
        //方法一:
        FileOutputStream fis = new FileOutputStream("output.txt");
        BufferedOutputStream bos = new BufferedOutputStream(fis);
        //方法二:
        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"));
    }
  • DataOutputStream(数据流)
    • DataOutputStream 用于写入指定类型数据,不能单独使用,必须结合 FileOutputStream
    public static void main(String[] args) throws IOException {
        //输出流
        FileOutputStream fos = new FileOutputStream("out.txt");
        DataOutputStream dos = new DataOutputStream(fos);
        //输出任意数据类型
        dos.writeBoolean(true);
        dos.writeByte(1);
    }

    注意:这个流可以将数据连同数据的类型一起写入文件。这个文件不是普通的文本文档,而是一个二进制文件。(记事本打不开)

  • ObjectOutputStream(反序列化)
    • ObjectInputStream 用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream 用于将对象写入到输出流 (序列化)
    public static void main(String[] args) throws IOException {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("file.txt"));
        Person person = new Person("张三",23);
        oos.writeObject(person);
    }

    注意:用于序列化的类必须实现 Serializable 接口,生成序列化版本号。对象中如果有属性不想被序列化,使用 transient 修饰。

3. 字符流

3.1 概述

3.1.1 为什么要有字符流

不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。那为什么I/O流操作要分为字节流操作和字符流操作呢?

  • 字符流是由Java虚拟机将字节转换得到的,这个过程还算是比较耗时的。
  • 如果我们不知道编码类型就很容易出现乱码问题。

因此,I/O流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

3.1.2 字符流采用编码

字符流默认采用的是Unicode编码,我们可以通过构造方法自定义编码。常用字符编码所占字节数:

  • utf-8:英文占1字节,中文占3字节
  • unicode:任何字符都占2个字节
  • gbk:英文占1字节,中文占2字节

3.2 Reader(字符输入流)

3.2.1 概述

Reader用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader抽象类是所有字符输入流的父类。

Reader 用于读取文本, InputStream 用于读取原始字节。

3.2.2 常用方法
方法 作用
read() 从输入流中读取一个字符。
read(char cbuf[]) 从输入流中读取一些字符,并将它们储存到字符数组 cbuf 中。等价于 read(cbuf, 0, cbuf.length)。
read(char cbuf[], int off, int len) 在read(char cbuf[])方法的基础上增加了 off 参数(偏移量)和 len 参数(要读取的最大字符数)。
skip(long n) 忽略输入流中的n个字符,返回实际忽略的字符数。
close() 关闭输入流释放相关的系统资源。

空参的read方法和有参的read方法的区别:
空参的read方法:一次读取一个字节,遇到中文一次读取多个字节,把字节解码并转成十进制返回
有参的read方法:把读取字节,解码,强转三步合并了,强转之后的字符放到数组中,返回的是读取的字符数

3.2.3 实现类
  • InputStreamReader(转换流)
    • InputStreamReader 是字节流转换为字符流的桥梁,其子类 FileReader 是基于该基础上的封装,可以直接操作字符文件
    • 内部有长度为8192的缓冲区,可以提高读取效率。
    //字节流转换为字符流的桥梁
    public class InpuStreamReader extends Reader{
    
    }
    //用于读取字符文件
    public class FileReader extends InputStreamReader{
    
    }
  • FileReader
    • 用字符流读取文件,可以指定编码格式(不指定时,使用平台默认的字符编码->通常为utf-8)。[只能读取纯文本文件]
    • 内部有长度为8192的缓冲区,可以提高读取效率。
        public static void main(String[] args) throws IOException {
        FileReader fid = new FileReader("input.txt");
        int content;
        long skip = fid.skip(3);
        System.out.println("The actual number of bytes skipped: " + skip);
        System.out.print("The content of the file: ");
        while ((content = fid.read()) != -1) {
            System.out.print((char) content);
        }
        fid.close();
    }
    /*
    * inout.txt内容:LL,我是Guide
    * 输出:
    * The actual number of bytes skipped:3
    * he content of the file:我是Guide
     * */

3.3 Writer(字符输出流)

3.3.1 概述

Writer用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer抽象类是所有字节输出流的父类。

3.3.2 常用方法
方法 作用
write(int c) 写入单个字符
write(char cbuf[]) 写入字符数组 cubf 中的所有字符。等价于 write(cbuf, 0, cbuf.length)。
write(char cbuf[], int off, int len) 在write(char cbuf[])方法的基础上增加了 off 参数(偏移量)和 len 参数(要写入的最大字符数)。
write(String str) 写入字符串 str 。等价于 write(str, 0, str.length)。
write(String str, int off, int len) 在write(String str)方法的基础上增加了 off 参数(偏移量)和 len 参数(要写入的最大字符数)。
append(CharSequence csq) 将指定的字符序列附加到指定的Writer对象并返回该Writer对象。[若要追加,在指定文件时第二个参数要传true]
append(char c) 将指定的字符附加到指定的Writer对象并返回该Writer对象。[若要追加,在指定文件时第二个参数要传true]
flush() 刷新输出流,将缓冲区中的数据写入目的地。
close() 关闭输出流,释放相关的系统资源。
3.3.3 实现类
  • OutputStreamWriter(转换流)
    • OutputStreamWriter 是字符流转换为字节流的桥梁,其子类 FileWriter 是基于该基础上的封装,可以直接将字符写入到文件。
    • 内部有长度为8192的缓冲区,可以提高读取效率。
    //字节流转换为字符流的桥梁
    public class OutputStreamWriter extends Writer{
    
    }
    //用于写入字符到文件
    public class FileWriter extends OutputStreamWriter{
    }
  • FileWriter
    • 不同系统的换行符号:
      • linux:\n
      • windows:\r\n
      • mac:\r
    • 内部有长度为8192的缓冲区,可以提高读取效率。
    public static void main(String[] args) throws IOException {
        FileWriter fw = new FileWriter("input.txt");
        fw.write("Hello World");
        fw.close();
    }
    // input.txt: Hello World

4. 缓冲流

4.1 为什么要有缓冲流

  • IO操作是很消耗性能的,缓冲流可以将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的IO操作,提高流的传输效率。

4.2 字节缓冲流

  • 字节缓冲流这里采用了修饰器模式来增强InputStream和OutputStream的功能,从而实现缓冲流。
  • 字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用write(int b) 和 read() 这两个一次只读一个字节的方法的时候。由于字节缓存流内部有缓冲区(一个长度为8192的字节数组),因此,字节缓冲流会将读取到的字节放在缓存区中,大幅减少IO次数,提高读取效率
  • 如果是调用read(byte[] b)和writer(byte[] b,int off,int len)这两个读取/写入一个字节数组的方法的话,只要字节数组的大小合适,两者的性能差距其实不大,基本可以忽略。
4.2.1 BufferedInputStream(字节缓冲输入流)
  • BufferedInputStream 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。
  • BufferedInputStream 内部维护了一个缓冲区,这个缓冲区实际就是一个长度为8192的字节(byte)数组,通过阅读 BufferedInputStream 源码即可得到这个结论。
    public class BufferedInputStream extends FilterInputStream {
      // 缓冲区的默认大小
      private static final int DEFAULT_BUFFER_SIZE = 8192;
      // 内部缓冲区数组
      private static final byte[] EMPTY = new byte[0];
      // 使用默认的缓冲区大小
      public BufferedInputStream(InputStream in) {
          this(in, DEFAULT_BUFFER_SIZE);
      }
      // 自定义缓冲区大小
      public BufferedInputStream(InputStream in, int size) {
          super(in);
          if (size <= 0) {
              throw new IllegalArgumentException("Buffer size <= 0");
          }
          initialSize = size;
          if (getClass() == BufferedInputStream.class) {
              // use internal lock and lazily create buffer when not subclassed
              lock = InternalLock.newLockOrNull();
              buf = EMPTY;
          } else {
              // use monitors and eagerly create buffer when subclassed
              lock = null;
              buf = new byte[size];
          }
      }
    }
  • 缓冲区的大小默认为 8192 字节,当然了,也可以通过 BufferedInputStream(InputStream in, int size) 这个构造方法来指定缓冲区的大小。
4.2.2 BufferedOutputStream(字节缓冲输出流)
  • BufferedOutputStream 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率。
  • 示例:往 output.txt 文件写内容:
    public static void main(String[] args) throws IOException {
         BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"));
         bos.write("Hello World".getBytes());
         bos.flush();
         bos.close();
     }
     // output.txt: Hello World
  • 类似于 BufferedInputStream ,BufferedOutputStream 内部也维护了一个缓冲区,并且,这个缓存区的大小也是 8192 字节。

4.3 字符缓冲流

  • BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和 BufferedOutputStream(字节缓冲输出流),内部都维护了一个长度为 8192 的字符(char)数组作为缓冲区。不过,前者主要是用来操作字符信息。
4.3.1 BufferedReader(字符缓冲输入流)
  • 特有方法:
    • public String readLine() throws IOException :从输入流中读取一行文本,并返回一个字符串。当达到文件末尾时,返回 null
    • public Stream<String> lines() throws IOException :返回一个Stream流,该流包含从输入流中读取的所有行。
    • public boolean ready() throws IOException :检查是否可以从流中读取数据而不是阻塞。
    public static void main(String[] args) {
        try (BufferedReader br = new BufferedReader(new FileReader("input.txt"))) {
            String str;
            while ((str = br.readLine()) != null) {
                System.out.println(str);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    /* 输出:Hello World 1
            Hello World 2
            Hello World 3
            Hello World 4
            Hello World 5
            Hello World 6*/
    public static void main(String[] args) throws IOException {
          BufferedReader br = new BufferedReader(new FileReader("input.txt"));
          br.lines().forEach(System.out::println);
      }
      /* 输出:Hello World 1
            Hello World 2
            Hello World 3
            Hello World 4
            Hello World 5
            Hello World 6*/
4.3.2 BufferedWriter(字符缓冲输出流)
  • 特有方法:
    • public void newLine() throws IOException :写入一个换行符。[跨平台的换行]
    public static void main(String[] args) throws IOException {
          BufferedWriter bw = new BufferedWriter(new FileWriter("output.txt"));
          bw.write("Hello World");
          bw.newLine();
          bw.write("Java");
          bw.flush();
          bw.close();
      }
      /* output.txt:  Hello World
                      Java*/

5. 打印流

  • PrintStream 属于字节打印流,是OutputStream的子类,可以将数据输出到指定的目的地。[默认输出到控制台]
  • PrintWriter 属于字符打印流,是Writer的子类,可以将数据输出到指定的目的地。[默认输出到控制台]

5.1 常用方法

方法 作用
println(参数类型不定x) 输出x带换行
print(参数类型不定x) 输出x不带换行
flush() 刷新输出流,将缓冲区中的数据写入目的地。
close() 关闭输出流,释放相关的系统资源。

改变流的输出方向:System.setOut(new PrintStream(new FileOutputStream(文件名)))

5.2 示例

public static void main(String[] args) throws IOException {
       // 可以改变标准输出流的输出方向吗? 可以// 标准输出流不再指向控制台,指向“log”文件。
       PrintStream printStream = new PrintStream(new FileOutputStream("log"));
       // 修改输出方向,将输出方向修改到"log"文件。
       System.setOut(printStream);// 修改输出方向
       // 再输出
       System.out.println("hello world");
       System.out.println("hello kitty");
       System.out.println("hello zhangsan");
       printStream.flush();
       printStream.close();
   }

6. 随机访问流

6.1 概述

随机访问流指的是支持随意跳转到文件的任意位置进行读写的RandomAccessFile。

6.2 构造方法

// openAndDelete 参数默认为false 表示打开文件并且这个文件不会被删除
public RandomAccessFile(File file,String mode){
    throws FileNotFoundException {
        this(file,mode,false);
    }
}
// 私有方法
private RandomAccessFile(File file,String mode,boolean opeanAndDelete){
    throws FileNotFoundException{
        // 省略大部分代码
    }
}

RandomAccessFile的构造方法如上,我们可以指定mode(读写模式):

  • r:只读模式
  • rw:读写模式
  • rws:相对于rw,rws同步更新对 “文件的内容” 或 “元数据” 的修改到外部储存设备。
  • rwd:相对于rw,rwd同步更新对 “文件的内容” 的修改到外部储存设备。
    文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性如文件的大小信息、创建和修改时间。

6.3 常用方法

方法 作用
RandomAccessFile(File file,String mode) mode取r(读)或rw(可读写)等,通过mode可以确定流对文件的访问权限
seek(long a) 将流的读写位置定位到距离文件开头 a 个字节处。
getFilePointer() 获取流的当前读写位置,返回的是一个 long 类型的值,表示距离文件开头的字节偏移量
read() 从文件中读取一个字节的数据,并返回读取到的字节。如果到达文件末尾,则返回 -1。这个方法每次只读取一个字节。
read(byte[] buffer) 从文件中读取一定数量的字节,并将它们存储到指定的字节数组 buffer 中。返回值为实际读取的字节数,如果到达文件末尾且没有更多的字节可供读取,则返回 -1;否则返回实际读取的字节数。
readFully(byte[] buffer) 从文件中读取指定数量的字节,并将它们存储到指定的字节数组 buffer 中。与 read() 方法不同的是,readFully() 方法更严格,如果文件中的数据不足以填满整个 buffer,则 readFully() 方法会抛出 EOFException 异常,而不是返回部分读取的数据。
write(int b) 向文件中写入一个字节的数据。参数 b 表示要写入的字节。
write(byte[] buffer) 向文件中写入指定字节数组 buffer 中的数据。写入的数据数量取决于 buffer 的长度。
writeBytes(String s) 向文件中写入指定的字符串 s。写入的数据是字符串 s 的字节表示形式。
writeUTF(String s) 向文件中写入指定的字符串 s,使用UTF-8编码。在写入时,会在字符串前面添加两个字节的长度信息,以便读取时能够正确地解析字符串。
length() 获取文件的长度,返回值为文件的字节数。
close() 关闭文件流,释放相关的系统资源。
getFD() 返回与此文件关联的文件描述符对象(FileDescriptor)[通常用于创建新的IO流,如FileInputStream、FileOutputStream、RandomAccessFile等]

注意:

  • 在 RandomAccessFile 中,seek(long a) 和 getFilePointer() 方法是按字节定位的,这意味着它们不会意识到文件中的字符编码。因此,如果文件中包含多字节字符(例如中文),直接按字节定位可能会导致位置计算错误。
  • 在使用 RandomAccessFile 写出数据时,数据以字节形式写出到文件中。

6.4 使用场景

RandomAccessFile 比较常见的一个应用就是实现大文件的 断点续传 。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。

6.5 示例

RandomAccessFile 读写数据

public static void main(String[] args) throws IOException {
       RandomAccessFile raf = new RandomAccessFile("input.txt", "rw");
       //将若干数据写入到文件中
       raf.write("\r\n忆高考".getBytes());
       raf.write("\r\n年年忆今朝,茫茫赶国考;".getBytes());
       raf.write("\r\n不成真秀才,只图自逍遥。".getBytes());
       raf.write("\r\n".getBytes());
       raf.write(97);
       raf.seek(0);
       BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(raf.getFD()))); //或者:BufferedReader br = new BufferedReader(new FileReader(raf.getFD()));
       String str;
       while ((str = br.readLine()) != null) {
           System.out.println(str);
       }
       br.close();
       raf.close();
   }
/* 输出: 忆高考
         年年忆今朝,茫茫赶国考;
         不成真秀才,只图自逍遥。
         a*/

RandomAccessFile 可以帮助我们合并文件分片,示例代码如下:

public boolean merge(String fileName){
    byte[] buffer = new byte[1024*10];
    int len = -1;
    try(RandomAccessFile oSaveFile = new RandomAccessFile(fileName,"rw")){
        for(int i=0;i<DOWNLOAD_THREAD_NUM;i++){
            try(BufferedInputStream bis = new BufferedInputStream(
                    new FileInputStream(fileName + FILE_TEMP_SUFFIX + i))){
                while ((len= bis.read(buffer)) != -1){
                    oSaveFile.write(buffer,0,len);
                }
            }
        }
        logUtils.inof("文件合并完毕 {}",fileName);
    } catch (Exception e){
        e.printStackTrace();
        return false;
    }
    return true;
}

分片上传常常使用在大文件的上传问题中,《Java 面试指北》中详细介绍了大文件的上传问题


文章作者: Lu
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Lu !
  目录
>