這些年一直記不住的 Java I/O

參考資料

  該文中的內容來源於 Oracle 的官方文檔。Oracle 在 Java 方面的文檔是非常完善的。對 Java 8 感興趣的朋友,可以從這個總入口 Java SE 8 Documentation 開始尋找感興趣的內容。本博客不定期從 Oracle 官網搬磚。這一篇主要講 Java 中的 I/O,官方文檔在這裏 Java I/O, NIO, and NIO.2

前言

  不知道大家看到這個標題會不會笑我,一個使用 Java 多年的老程序員居然一直沒有記住 Java 中的 I/O。不過說實話,Java 中的 I/O 確實含有太多的類、接口和抽象類,而每個類又有好幾種不同的構造函數,而且在 Java 的 I/O 中又廣泛使用了 Decorator 設計模式(裝飾者模式)。總之,即使是在 OO 領域浸淫多年的老手,看到下面這樣的調用一樣會蛋疼:

BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("somefile.txt")));

  當然,這僅僅只是我爲了體現 Java I/O 的錯綜複雜的構造函數而虛構出來的一個例子,現實中創建一個 BufferedReader 很少會嵌套這麼深,因爲可以直接使用 FileReader 而避免多創建一個 FileInputStream。但是從一個 InputStream 轉化成一個 BufferedReader 總是有那麼幾步路要走的,比如下面這個例子:

URL cnblogs = new URL("http://www.cnblogs.com/");
BufferedReader reader = new BufferedReader(new InputStreamReader(cnblogs.openStream()));

  Java I/O 涉及到的類也確實特別多,不僅有分別用於操作字符流和字節流的 InputStream 和 Reader、OutputStream 和 Writer,還有什麼 BufferedInputStream、BufferedReader、PrintWriter、PrintStream等,還有用於溝通字節流和字符流的橋樑 InputStreamReader 和 OutputStreamWriter,每一個類都有其不同的應用場景,如此細緻的劃分,光是名字就足夠讓人暈頭轉向了。

  我一直記不住 Java I/O 中各種細節的另一個原因可能是我深受 ANSI C 的荼毒吧。在 C 語言的標準庫中,將文件的打開方式分爲兩種,一種是將文件當成二進制格式打開,一種是當成文本格式打開。這和 Java 中的字節流和字符流的劃分有相似之處,但卻掩蓋了所有的數據其實都是字節流這樣的本質。ANSI C 用多了,總以爲二進制格式和文本格式是同一個層面的兩種對立面,只能對立而不能統一,卻不知在 Java 中,字符流是對字節流的更高層次的封裝,最底層的 I/O 都是建立在字節流的基礎上的。如果拋開 ANSI C 語言的標準 I/O 庫,直接考察操作系統層面的 POSIX I/O,會發現操作的一切都是原始的字節數據,根本沒有什麼字節字符的區別。

  除此之外,Java 走得更遠,它考慮到了各種更加廣泛的字節流,而不僅僅限於文件。比如網絡中傳輸的數據、內存中傳輸的對象等等,都可以用流來抽象。但是不同的流具有不同的特性,有的流可以隨機訪問,而有的卻只能順序訪問,有的可以解釋爲字符,有的不能。在能解釋爲字符的流中,有的一次只能訪問一個字符,有的卻可以一次訪問一行,而且把字節流解釋成字符流,還要考慮到字符編碼的問題。

  以上種種,均是造成 Java I/O 中類和接口多、對象構造方式複雜的原因。

從對立到統一,字節流和字符流

  先來說對立。在 Java 中如果要把流中的數據按字節來訪問,就應該使用 InputStream 和 OutputStream,如果要把流中的數據按字符來訪問,就應該使用 Reader 和 Writer。上面提到的這四個類都是抽象類,是所有其它具體類的基礎。不能直接構造 InputStream、OutputStream、Reader 和 Writer 類的實例,但是根據 OO 原則,可以這樣用:

InputStream in = new FileInputStream("somefile");
int c = in.read();

  或者這樣:

Reader reader = new FileReader("somefile");
int c = reader.read();

  這裏的 FileInputStream 和 FileReader 就是具體的類,這樣的類還有很多,都位於 java.io 包中。文件讀寫是我們最常用的操作,所以最常用的就是 FileInputStream、FileOutputStream、FileReader、FileWriter這四個。這幾個類的構造函數有多個,但是最簡單的,肯定是接受一個代表文件路徑的字符串做參數的那一個。根據 OO 原則,我們一般使用更加抽象的 InputStream、OutputStream、Reader、Writer 來引用具體的對象。所以,在考察 API 的時候,只需要考察這四個抽象類就可以了,其它的具體類,基本上只需要考察它們的構造方式。

  而這幾個類的 API 也確實很好記,用來輸入的兩個類 InputStream 和 Reader 主要定義了read()方法,而用來輸出的兩個類 OutputStream 和 Writer 主要定義了write()方法。所不同者,前者操作的是字節,後者操作的是字符。read()write()最簡單的用法是這樣的:

package com.xkland.sample;

import java.io.InputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.FileNotFoundException;

public class JavaIODemo {
    public static void main(String[] args) {
        if(args.length < 1){
            System.out.println("Usage: JavaIODemo filename");
            return;
        }
        String somefile = args[0];
        InputStream in = null;
        try{
            in = new FileInputStream(somefile);
            int c;
            while((c = in.read()) != -1) {  //這裏用到read()
                System.out.write(c);        //這裏用到write()
            }
        }catch(FileNotFoundException e){
            System.out.println("File not found.");
        }catch(IOException e){
            System.out.println("I/O failed.");
        }finally{
            if(in != null){
                try {
                    in.close();
                }catch(IOException e){
                    //關閉流時產生的異常,直接拋棄
                }
            }
        }
    }
}

  上面的例子展示了read()write()的用法,在 InputStream 和 OutputStream 中,這兩個方法操作的都是字節,但是,這裏用來保存這個字節的變量卻是int類型的。這正是 API 設計的匠心所在,因爲int的寬度明顯比byte要大,所以將一個byte讀入到一個int之後,有效的數據只佔據int型變量的最低8位,如果read()方法返回的是有效數據,那麼這個int型的變量永遠都不可能是負數。在這種情況下,read()方法可以用返回負數的方式來表示碰到特殊情況,比如返回-1表示到達了流的末尾,也就是用-1代表EOFwrite()方法接受的參數也是int型的,但是它只把這個int型變量的最低8位寫入流,其餘的數據被忽略。

  上面的例子還展示了 Java I/O 的一些特徵:

  1. InputStream、OutputStream、Reader、Writer 等資源用完之後要關閉;
  2. 所有的 I/O 操作都可能產生異常,包括調用close()方法。

  這兩個特徵攪到一起就比較複雜了,本來因爲異常的產生就容易讓流的close()語句執行不到,所以只有把close()寫到finally塊中,但是在finally塊中調用close()又要寫一層try...catch...代碼塊。如果同時有多個流需要關閉,而前面的close()拋出異常,則後面的close()將不會執行,極易發生資源泄露。再加上如果前面的catch()塊中的異常被重新拋出,而finally塊中又沒有處理好異常的話,前面的異常會被抑制,所以大部分人都 hold 不住這樣的代碼,包括 Oracle 的官方教程中的寫法都是錯誤的。下面來看一下 Oracle 官方教程中的例子:

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

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

        FileInputStream in = null;
        FileOutputStream out = null;

        try {
            in = new FileInputStream("xanadu.txt");
            out = new FileOutputStream("outagain.txt");
            int c;

            while ((c = in.read()) != -1) {
                out.write(c);
            }
        } finally {
            if (in != null) {
                in.close();
            }
            if (out != null) {
                out.close();
            }
        }
    }
}

  官方教程寫得比我更偷懶,它直接讓main()方法拋出IOException而避免了異常處理,也避免了在finally塊中的close()語句外再寫一層try...catch...。但是,這個示例的漏洞有兩個,其一是如果in.close()拋出了異常,則out.close()就不會執行;其二是如果try塊中拋出了異常,finally塊中又拋出了異常,則前面拋出的異常會被丟棄。爲了解決這個問題,Java 7中新加入了try-with-resource語法。後面都用這種方式寫代碼。

  很顯然,一次處理一個字節效率是及其低下的,所以read()write()還有別的重載版本:

int read(byte[] b)
int read(byte[] b, int off, int len)
void write(byte[] b)
void write(byte[] b, int off, int len)

  它們都可以一次操作一塊數據,用字節數組做爲存儲數據的容器。read()返回的是實際讀取的字節數。而對於 Reader 和 Writer,它們的read()write()方法的定義是這樣的:

int read()
int read(char[] cbuf)
int read(char[] cbuf, int off, int len)
void write(int c)
void write(char[] cbuf)
void write(char[] cbuf, int off, int len)
void write(String str)
void write(String str, int off, int len)

  可以看出,使用 Reader 和 Writer 一次操作一個字符的時候,依然使用的是int型的變量。如果一次操作一塊數據,則使用字符數組。輸出的時候,還可以直接使用字符串。

  到這裏,已經可以很輕易記住八個類了:InputStream、OutputStream、Reader、Writer、FileInputStream、FileOutputStream、FileReader、FileWriter。前四個是抽象類,後四個是操作文件的具體類。而且這八個類分成兩組,一組操作字節流,一組操作字符流。很簡單的對立分組。

  然而,前面我提到過,其實字節流和字符流並不是完全對立的存在,其實字符流是在字節流上更高層次的封裝。在底層,一切數據都是字節,但是經過適當的封裝,可以把這些字節解釋成字符。而且,並不是所有的 Reader 都是可以像 FileReader 那樣直接創建的,有時,只能拿到一個可以讀取字節數據的 InputStream,卻需要在它之上封裝出一個 Reader,以方便按字符的方式讀取數據。最典型的例子就是可以這樣訪問一個網頁:

URL cnblogs = new URL("http://www.cnblogs.com/");
InputStream in = cnblogs.openStream();

  這時,拿到的是字節流 InputStream,如果想獲得按字符讀取數據的 Reader,可以這樣創建:

Reader reader = new InputStreamReader(in);

  所以, InputStreamReader 是溝通字節流和字符流的橋樑。同樣的橋樑還用用於輸出的 OutputStreamWriter。至此,不僅又輕鬆地記住了兩個類,也再次證明了字節流和字符流既對立又統一的辯證關係。

從抽象到具體,數據的來源和目的

  InputStream、OutputStream、Reader 和 Writer 是抽象的,根據不同的數據來源和目的又有不同的具體類。前面的例子中提到了基於 File 的流,也初步展示了一個基於網絡的流。結合平時使用計算機的經驗,我們也可以想到其它一些不同的數據來源和目的,比如從內存中讀取字節或把字節寫入內存,從字符串中讀取字符或者把字符寫入字符串等等,還有從管道中讀取數據和向管道中寫入數據等等。根據不同的數據來源和目的,可以有這樣一些具體類:FileInputStream、ByteArrayInputStream、PipedInputStream、FileOutputStream、ByteArrayOutputStream、PipedOutputStream、FileReader、StringReader、CharArrayReader、PipedReader、FileWriter、StringWriter、CharArrayWriter、PipedWriter等。從這些類的命名可以看出,凡是以Stream結尾的,都是操作字節的流,凡是以 Reader 和 Writer 結尾的,都是操作字符的流。只有 InputStreamReader 和 OutputStreamWriter 是例外,它是溝通字節和字符的橋樑。對於這些具體類,使用起來是沒有什麼困難的,只需要考察它們的構造函數就可以了。下面兩幅 UML 類圖可以展示這些類的關係。

  InputStreams 和 Readers:

  OutputStreams 和 Writers:

從簡單到豐富,使用 Decorator 模式擴展功能

  從前文可以看出,所有的流都支持read()write(),但是這樣的功能畢竟還是太簡單,有時還需要更高層次的功能需求,所以需要使用 Decorator 模式來對流進行擴展。比如,一次操作一個字節或一個字符效率太低,想把數據先緩存在內存中再進行操作,就可以擴展出 BufferedInputStream、BufferedReader、BufferedOutputStream、BufferedWriter 類。可以猜測到,BufferedOutputStream 和 BufferedWriter 類中一定有一個flush()方法,用來把緩存的數據寫入到流中。而且,BufferedReader 還有 readLine() 方法,可以一次讀取一行字符,甚至可以再擴展出一個 LineNumberReader,還可以提供行號的支持。再比如,有時從流中讀出一個字節或一個字符後,又不想要了,想把它還回去,就可以再擴展出 PushbackInputStream 和 PushbackReader,提供unread()方法將剛讀取的字節或字符還回去。可以想象,這種還回去的功能應該是需要緩存功能支持的,所以它們應該是在 BufferedInputStream 和 BufferedReader 外面又加了一層的裝飾。這就是 Decorator 模式。

  Java I/O 中自帶的這種擴展類還有很多,不容易記。後面的介紹中,會針對重要的類舉幾個例子。在此之前,還是通過 UML 類圖來了解一下擴展類。

  從 InputStream 擴展的類:

  從 Reader 擴展的類:

  從 OutputStream 擴展的類:

  從 Writer 擴展的類:

  從上圖中可以看到,每一個分組中擴展的類的數量是不一樣的,再也不是一種對稱的關係。仔細一想也很好理解,例如 Pushback 這樣的功能就只能用在輸入流 InputStream 和 Reader 上,而向輸出流中寫入數據就像潑出去的水,沒辦法再 Pushback 了。再例如,向流中寫入對象和讀取對象,操作的肯定是字節流而不是字符流,所以只有 ObjectInputStream 和 ObjectOutputStream,而沒有相應的 Reader 和 Writer 版本。再例如打印,操作的肯定是輸出流,所以只有 PrintStream 和 PrintWriter,沒有相應的輸入流版本,這沒有什麼好奇怪的。

  在這些類中,可以通過 PrintStream 和 PrintWriter 向流中寫入格式化的文本,也可以通過 DataInputStream 和 DataOutputStream 從流中讀取或向流中寫入原始的數據,還可以通過 ObjectInputStream 和 ObjectOutputStream 從流中讀取或寫入一個完整的對象。如果要從流中讀取格式化的文本,就必須使用 java.util.Scanner 類了。

  下面先看一個簡單的示例,使用 DataOutputStream 的writeInt()writeDouble()以及writeUTF()方法將intdoubleString類型的數據寫入流中,然後再使用 DataInputStream 的readInt()readDouble()readUTF()方法從流中讀取intdoubleString類型的數據。爲了簡單起見,就使用基於文件的流作爲存儲數據的方式。代碼如下:

package com.xkland.sample;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.EOFException;

public class DataStreamsDemo {
    public static void writeToFile(String filename){

        double[] prices = { 19.99, 9.99, 15.99, 3.99, 4.99 };
        int[] units = { 12, 8, 13, 29, 50 };
        String[] descs = {
            "Java T-shirt",
            "Java Mug",
            "Duke Juggling Dolls",
            "Java Pin",
            "Java Key Chain"
        };
        try(DataOutputStream out = new DataOutputStream(
                new BufferedOutputStream(
                        new FileOutputStream(filename)))){
            for (int i = 0; i < prices.length; i ++) {
                out.writeDouble(prices[i]);
                out.writeInt(units[i]);
                out.writeUTF(descs[i]);
            }
            
        }catch(IOException e){
            System.out.println(e.getMessage());
        }
    }
    
    public static void readFromFile(String filename){
        double price;
        int unit;
        String desc;
        double total = 0.0;
        try(DataInputStream in = new DataInputStream(
                new BufferedInputStream(
                        new FileInputStream(filename)))){
            while (true) {
                price = in.readDouble();
                unit = in.readInt();
                desc = in.readUTF();
                System.out.format("You ordered %d" + " units of %s at $%.2f%n", unit, desc, price);
                total += unit * price;
            }
            
        }catch(EOFException e){
            //達到文件末尾
            System.out.format("所有數據已讀入,總價格爲:$%.2f%n", total);
        }catch(IOException e){
            System.out.println(e.getMessage());
        }
    }
}

  然後在main()方法中這樣調用:

package com.xkland.sample;

public class JavaIODemo {

    public static void main(String[] args) {
        if(args.length < 1){
            System.out.println("Usage: JavaIODemo filename");
            return;
        }
        //向文件中寫入數據
        DataStreamsDemo.writeToFile(args[0]);
        //從文件中讀取數據並顯示
        DataStreamsDemo.readFromFile(args[0]);
    }

}

  然後這樣運行該程序:

java com.xkland.sample.JavaIODemo /home/youxia/testfile

  最後輸出是這樣:

You ordered 12 units of Java T-shirt at $19.99
You ordered 8 units of Java Mug at $9.99
You ordered 13 units of Duke Juggling Dolls at $15.99
You ordered 29 units of Java Pin at $3.99
You ordered 50 units of Java Key Chain at $4.99
所有數據已讀入,總價格爲:$892.88

  如果使用cat命令查看/home/youxia/testfile文件的內容,只會看到一堆亂碼,說明該文件是以二進制格式存儲的。如下:

youxia@ubuntu:~$cat testfile
@3�p��
=

Duke Juggling Dolls@���Q�Java Pin@�\(�2Java Key Chain

  上面的代碼展示了 DataInputStream 和 DataOutputStream 的用法,通過前面的探討,對它們這樣層層包裝的構造方式已經見怪不怪了。並且在示例代碼中使用了 Java 7 中新引入的try-with-resource語法,這樣大大減少了代碼的複雜度,所有打開的流都可以自動關閉,而且異常處理也更簡潔。從代碼中還可以看到,需要捕獲 DataInputStream 的 EOFException 異常才能判斷讀取到了文件結尾。另外,使用這種方式寫入和讀取數據要非常小心,寫入數據的順序和讀取數據的順序一定要保持一致,如果先寫一個int,再寫一個double,則一定要先讀一個int,再讀一個double,否則只會讀取錯誤的數據。不信可以通過修改上述示例代碼中讀取數據的順序進行測試。

  使用 DataInputStream 和 DataOutputStream 只能寫入和讀取原始的數據類型的數據,如bytecharshortfloat等,如果要讀取和寫入複雜的對象就不行了,比如java.math.BigDecimal。這個時候就需要使用 ObjectInputStream 和 ObjectOutputStream 了。所有需要寫入流和從流讀取的 Object 必須實現Serializable接口,然後調用 ObjectInputStream 和 ObjectOutputStream 的writeObject()方法和readObject()方法就可以了。而且很奇妙的是,如果一個 Object 中包含了其它的 Object 對象,則這些對象都會被寫入到流中,而且能保持它們之間的引用關係。從流中讀取對象的時候,這些對象也會同時被讀入內存,並保持它們之間的引用關係。如果把同一批對象寫入不同的流,再從這些流中讀出,就會獲得這些對象多個副本。這裏就不舉例了。

  與以二進制格式寫入和讀取數據相對的,就是以文本的方式寫入和讀取數據。PrintStream 和 PrintWriter 中的 Print 就是代表着輸出能供人讀取的數據。比如浮點數3.14可以輸出爲字符串"3.14"。利用 PrintStream 和 PrintWriter 中提供的大量print()方法和println()方法就可以做到這點,利用format()方法還可以進行更加複雜的格式化。把上面的例子做少量修改,如下:

package com.xkland.sample;

import java.io.*;
import java.util.Scanner;

public class PrintStreamDemo {
    public static void writeToFile(String filename){

        double[] prices = { 19.99, 9.99, 15.99, 3.99, 4.99 };
        int[] units = { 12, 8, 13, 29, 50 };
        String[] descs = {
                "Java T-shirt",
                "Java Mug",
                "Duke Juggling Dolls",
                "Java Pin",
                "Java Key Chain"
        };
        try(PrintStream out = new PrintStream(
                new BufferedOutputStream(
                        new FileOutputStream(filename)))){
            for (int i = 0; i < prices.length; i ++) {
                out.println(prices[i]);
                out.println(units[i]);
                out.println(descs[i]);
            }

        }catch(IOException e){
            System.out.println(e.getMessage());
        }
    }

    public static void readFromFile(String filename){
        double price;
        int unit;
        String desc;
        double total = 0.0;
        try(Scanner s = new Scanner(new BufferedReader(new FileReader(filename)))){
            s.useDelimiter("\n");
            while (s.hasNext()) {
                price = s.nextDouble();
                unit = s.nextInt();
                desc = s.next();
                System.out.format("You ordered %d" + " units of %s at $%.2f%n", unit, desc, price);
                total += unit * price;
            }
            System.out.format("所有數據已讀入,總價格爲:$%.2f%n", total);
        }catch(IOException e){
            System.out.println(e.getMessage());
        }
    }
}

  這時輸出的數據和輸入的數據都是經過良好格式化的,非常便於閱讀和打印,但是在處理數據的時候需要進行適當的轉換和解析,所以會一定程度上影響效率。在使用java.util.Scanner時,可以使用useDelimiter()方法設置合適的分隔符,在 Linux 系統中,空格、冒號、逗號都是常用的分隔符,具體情況具體分析。在上面的例子中,我直接將每個數據作爲一行保存,這樣更加簡單。如果使用cat命令查看/home/youxia/testfile文件的內容,可以看到格式良好的數據,如下:

youxia@ubuntu:~$ cat testfile
19.99
12
Java T-shirt
9.99
8
Java Mug
15.99
13
Duke Juggling Dolls
3.99
29
Java Pin
4.99
50
Java Key Chain

  如果不想使用流,只想像 C 語言那樣簡單地操作文件,可以使用 RandomAccessFile 類。

  對於 PrintStream 和 PrintWriter,我們用得最多的就是基於命令行的標準輸入輸出,也就是從鍵盤讀入數據和向屏幕寫入數據。Java 中有幾個內建的對象,它們分別是 System.in、System.out、System.err,因爲平時用得多,我就不一一細講了。需要說明的是,這幾個對象都是字節流而不是字符流,這也可以理解,雖然我們的鍵盤不能輸入純二進制數據,但是通過管道和文件重定向卻可以,在控制檯中輸出亂碼也是常見的現象,所以這幾個流必須是字節流而不是字符流。如果要想按字符的方式讀取標準輸入,可以使用 InputStreamReader 這樣轉換一下:

InputStreamReader cin = new InputStreamReader(System.in);

  除此之外,還可以使用 System.console 對象,它是 Console 類的一個實例。它提供了幾個實用的方法來操作命令行,如readLine()readPassword()等,它的操作是基於字符流的。不過在使用 System.console 之前,先要判斷它是否存在,如果操作系統不支持或程序運行在一個沒有命令行的環境中,則其值爲null

Java 7 中引入的 NIO.2

  早在 2002 年發佈的 Java 1.4 中就引入了所謂的 New I/O,也就是 NIO。但是依然被打臉, NIO 還是不那麼好用,還白白浪費了 New 這個詞,搞得 Java 7 中對 I/O 的改進不得不稱爲 NIO.2。在 Java 7 之前的 I/O 怎麼不好用呢?主要表現在以下幾點:

  1. 在不同的操作系統中,對文件名的處理不一致;
  2. 不方便對目錄樹進行遍歷;
  3. 不能處理符號鏈接;
  4. 沒有一致的文件屬性模型,不能方便地訪問文件的屬性。

  所以,雖然存在java.io.File類,我前文中卻沒有介紹它。在 Java 7 中,引入了 Path、Paths、Files等類來對文件進行操作。Path 代表文件的路徑,不同操作系統有不同的文件路徑格式,而且還有絕對路徑和相對路徑之分。可以這樣創建路徑:

Path absolute = Paths.get("/", "home", "youxia");
Path relative = Paths.get("myprog", "conf", "user.properties");

  靜態方法Paths.get()可以接受一個或多個字符串,然後它將這些字符串用文件系統默認的路徑分隔符連接起來。然後它對結果進行解析,如果結果在指定的文件系統上不是一個有效的路徑,那麼它會拋出一個 InvalidPathException 異常。當然,也可以給該方法傳遞一個含有分隔符的字符串:

Path home = Paths.get("/home/youxia");

  Path 類提供很多有用的方法對路徑進行操作。例如:

Path home = Paths.get("/home/youxia");
Path conf = Paths.get("myprog", "conf", "user.properties");
home.resolve(conf);   // 返回"/home/youxia/myprog/conf/user.properties"
Path another_home = Paths.get("/home/another");
home.relativize(another_home);   //返回相對路徑"../another"
Paths.get("/home/youxia/../another/./myprog").normalize();    //去掉路徑中冗餘,返回"/home/another/myprog"
conf.toAbsolutePath();    //根據程序的運行目錄返回絕對路徑,如過在用戶的根目錄中啓動程序,則返回"/home/youxia/myprog/conf/user.properties"
conf.getParent();    //獲得路徑的不含文件名的部分,返回"myprog/conf/"
conf.getFileName();    //獲得文件名,返回"user.properties"
conf.getRoot();    //獲得根目錄

  使用 Files 類可以快速實現一些常用的文件操作。例如,可以很容易地讀取一個文件的全部內容:

byte[] bytes = Files.readAllBytes(path);

  如果想將文件內容解釋爲字符串,可以在 readAllBytes 後調用:

String content = new String(bytes, StandardCharsets.UTF_8);

  也可以按行來讀取文件:

List<String> lines = Files.readAllLines(path);

  反過來,將一個字符串寫入文件:

Files.write(path, content.getBytes(StandardCharsets.UTF_8));

  按行寫入:

Files.write(path, lines);

  將內容追加到指定文件中:

Files.write(path, lines, StandardOpenOption.APPEND);

  當然,仍然可以使用前文介紹的 InputStream、OutputStream、Reader、Writer 類。這樣創建它們:

InputStream in = Files.newInputStream(path);
OutputStream out = Files.newOutputStream(path);
Reader reader = Files.newBufferedReader(path);
Writer in = Files.newBufferedWriter(path);

  同時,使用Files.copy()方法,可以簡化某些工作:

Files.copy(in, path);    //將一個 InputStream 中的內容保存到一個文件中
Files.copy(path, out);   //將一個文件的內容複製到一個 OutputStream 中

  一些創建、刪除、複製、移動文件和目錄的操作:

Files.createDirectory(path);    //創建一個新目錄
Files.createFile(path);         //創建一個空文件
Files.exists(path);             //檢測一個文件或目錄是否存在
Files.createTempFile(prefix, suffix);  //創建一個臨時文件
Files.copy(fromPath, toPath);   //複製一個文件
Files.move(fromPath, toPath);   //移動一個文件
Files.delete(path);             //刪除一個文件

  如果目標文件或目錄存在的話,copy()move()方法會失敗。如果希望覆蓋一個已存在的文件,可以使用StandardCopyOption.REPLACE_EXISTING選項。也可以指定使用原子方式來執行移動操作,這樣要麼移動操作成功完成,要麼源文件依然存在,可以使用StandardCopyOption.ATOMIC_MOVE選項。

  可以通過Files.isSymbolicLink()方法判斷一個文件是否是符號鏈接,還可以通過File.readSymbolicLink()方法讀取該符號鏈接目標的真實路徑。關於文件屬性,Java 7 中提供了 BasicFileAttributes 對真正通用的文件屬性進行了抽象,對於更具體的文件屬性,還提供了 PosixFileAttributes 等類。可以使用Files.readAttributes()方法讀取文件的屬性。關於符號鏈接和屬性,來看一個示例:

package com.xkland.sample;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFileAttributes;

public class JavaIODemo {
    public static void main(String[] args) {
        if(args.length < 1){
            System.out.println("Usage: JavaIODemo filename");
            return;
        }
        Path path = Paths.get(args[0]);
        Path real = null;
        try{
            if(Files.isSymbolicLink(path)){
                real = Files.readSymbolicLink(path);
            }
            PosixFileAttributes attr = Files.readAttributes(path, PosixFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
            System.out.format("%s, size: %d, isSymbolicLink: %b .", path, attr.size(), attr.isSymbolicLink());
            System.out.println();
            PosixFileAttributes attrOfReal = Files.readAttributes(real, PosixFileAttributes.class);
            System.out.format("%s, size: %d, isSymbolicLink: %b .", real, attrOfReal.size(), attrOfReal.isSymbolicLink());
            System.out.println();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

  如果這樣運行程序,可以查看/etc/alternatives/js文件是否是符號鏈接,並查看具體鏈接到哪個文件:

youxia@ubuntu:~$java com.xkland.sample.JavaIODemo /etc/alternatives/java
/etc/alternatives/java, size: 35, isSymbolicLink: true .
/usr/java/jdk1.8.0_102/jre/bin/java, size: 7734, isSymbolicLink: false .

  NIO.2 API 會默認跟隨符號鏈接,如果不要上述示例代碼中的LinkOption.NOFOLLOW_LINKS選項,則Files.readAttributes()返回的結果就是實際文件的屬性,而不是符號鏈接文件的屬性。

NIO.2 中的異步 I/O

  由於 I/O 操作經常會阻塞,所以編寫異步 I/O 操作的代碼從來都是提高程序運行效率的有效手段。特別是 Node.js 的出現,使異步 I/O 的影響達到空前的巨大,基於 Callback 的異步 I/O 早已深入人心。 Java 7 中有三個新的異步通道:

  1. AsynchronousFileChannel —— 用於文件 I/O;
  2. AsynchronousSocketChannel —— 用於套接字 I/O,支持超時;
  3. AsynchronousServerSocketChannel —— 用於套接字接受異步鏈接。

  這裏只考察一下基於文件的異步 I/O。使用異步 I/O 有兩種形式,一種是基於 Future,一種是基於 Callback。使用 Future 的示例代碼如下:

try{
    Path file = Paths.get("/home/youxia/testfile");
    AsynchronousFileChannel channel = AsynchronousFileChannel.open(file);    //異步打開文件
    ByteBuffer buffer = ByteBuffer.allocate(100_000);
    Future<Integer> result = channel.read(buffer, 0);    //讀取 100 000 字節
    while(!result.isDone()){
        //乾點兒別的事情
    }
    Integer bytesRead = result.get();    //獲取結果
    System.out.println("已讀取的字節數:" + bytesRead);
}catch(IOException | ExecutionException | InterruptedException e){
    System.out.println(e.getMessage());
}

  如果使用基於 Callback 的異步 I/O,其示例代碼是這樣的:

try{
    Path file = Paths.get("/home/youxia/testfile");
    AsynchronousFileChannel channel = AsynchronousFileChannel.open(file);
    ByteBuffer buffer = ByteBuffer.allocate(100_000);  //異步方式打開文件,分配緩衝區準備讀取,和前面是一樣的

    channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>(){

        public void completed(Integer result, ByteBuffer attachment){
            System.out.println("已讀取的字節數:" + bytesRead);
        }

        public void failed(Throwable exception, ByteBuffer attachment){
            System.out.println(exception.getMessage());
        }
    });  //調用 channel.read() 的另一個版本,接受一個 CompletionHandler 類的對象做參數

}catch(IOException e){
    System.out.println(e.getMessage());
}

  在這裏,創建了一個回調對象,該對象有completed()方法和failed()方法,根據 I/O 操作是否成功相應的方法會被回調,這和 Node.js 中的異步 I/O 是何其的相似啊。

總結

  寫完這一篇,估計我是再也不會忘記 Java I/O 的用法了。認真讀完我這一篇的朋友應該也一樣,如果讀一遍又忘記了的話,就多讀幾遍。當然,我這一篇文章仍不可能包含 Java I/O 的方方面面。關於具體的 API,大家直接查看 Oracle 的官方文檔就可以了。讀到這裏的朋友,請不要忘記給個推薦,謝謝。

(京山遊俠於2016-09-30發佈於博客園,轉載請註明出處。)


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章