Java編程的邏輯 (58) - 文本文件和字符流


本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營鏈接http://item.jd.com/12299018.html


上節我們介紹了如何以字節流的方式處理文件,我們提到,對於文本文件,字節流沒有編碼的概念,不能按行處理,使用不太方便,更適合的是使用字符流,本節就來介紹字符流。

我們首先簡要介紹下文本文件的基本概念、與二進制文件的區別、編碼、以及字符流和字節流的區別,然后我們介紹Java中的主要字符流,它們有:

  • Reader/Writer:字符流的基類,它們是抽象類。
  • InputStreamReader/OutputStreamWriter:適配器類,輸入是InputStream,輸出是OutputStream,將字節流轉換為字符流。
  • FileReader/FileWriter:輸入源和輸出目標是文件的字符流。
  • CharArrayReader/CharArrayWriter: 輸入源和輸出目標是char數組的字符流。
  • StringReader/StringWriter:輸入源和輸出目標是String的字符流。
  • BufferedReader/BufferedWriter:裝飾類,對輸入輸出流提供緩沖,以及按行讀寫功能。
  • PrintWriter:裝飾類,可將基本類型和對象轉換為其字符串形式輸出的類。

除了這些類,Java中還有一個類Scanner,類似於一個Reader,但不是Reader的子類,可以讀取基本類型的字符串形式,類似於PrintWriter的逆操作。

理解了字節流和字符流后,我們介紹一下Java中的標准輸入輸出和錯誤流。

最后,我們總結一些簡單的實用方法。

基本概念

文本文件

上節我們提到,處理文件要有二進制思維。從二進制角度,我們通過一個簡單的例子解釋下文本文件與二進制文件的區別,比如說要存儲整數123,使用二進制形式保存到文件test.dat,代碼為:

DataOutputStream output = new DataOutputStream(new FileOutputStream("test.dat"));
try{
    output.writeInt(123);
}finally{
    output.close();
}

使用UltraEdit打開該文件,顯示的卻是:

{                        

打開十六進制編輯器,顯示的為:

在文件中存儲的實際有四個字節,最低位字節7B對應的十進制數是123,也就是說,對int類型,二進制文件保存的直接就是int的二進制形式。這個二進制形式,如果當成字符來解釋,顯示成什么字符則與編碼有關,如果當成UTF-32BE編碼,解釋成的就是一個字符,即{。

如果使用文本文件保存整數123,則代碼為:

OutputStream output = new FileOutputStream("test.txt");
try{
    String data = Integer.toString(123);
    output.write(data.getBytes("UTF-8"));
}finally{
    output.close();
}

代碼將整數123轉換為字符串,然后將它的UTF-8編碼輸出到了文件中,使用UltraEdit打開該文件,顯示的就是期望的:

123

打開十六進制編輯器,顯示的為:

文件中實際存儲的有三個字節,31 32 33對應的十進制數分別是49 50 51,分別對應字符'1','2','3'的ASCII編碼。

編碼

在文本文件中,編碼非常重要,同一個字符,不同編碼方式對應的二進制形式可能是不一樣的,我們看個例子,對同樣的文本:

hello, 123, 老馬

UTF-8編碼,十六進制為:

英文和數字字符每個占一個字節,而每個中文占三個字節。

GB18030編碼,十六進制為:


英文和數字字符與UTF-8編碼是一樣的,但中文不一樣,每個中文占兩個字節。

UTF-16BE編碼,十六進制為:


無論是英文還是中文字符,每個字符都占兩個字節。UTF-16BE也是Java內存中對字符的編碼方式。

字符流

字節流是按字節讀取的,而字符流則是按char讀取的,一個char在文件中保存的是幾個字節與編碼有關,但字符流給我們封裝了這種細節,我們操作的對象就是char。

需要說明的是,一個char不完全等同於一個字符,對於絕大部分字符,一個字符就是一個char,但我們之前介紹過,對於增補字符集中的字符,比如'💎',它需要兩個char表示,對於這種字符,Java中的字符流是按char而不是一個完整字符處理的。

理解了文本文件、編碼和字符流的概念,我們再來看Java中的相關類,從基類開始。

Reader/Writer

Reader

Reader與字節流的InputStream類似,也是抽象類,主要有如下方法:

public int read() throws IOException
public int read(char cbuf[]) throws IOException
abstract public int read(char cbuf[], int off, int len) throws IOException;
abstract public void close() throws IOException;
public long skip(long n) throws IOException
public boolean markSupported()
public void mark(int readAheadLimit) throws IOException
public void reset() throws IOException
public boolean ready() throws IOException

方法的名稱和含義與InputStream中的對應方法基本類似,但Reader中處理的單位是char,比如read讀取的是一個char,取值范圍為0到65535。Reader沒有available方法,對應的方法是ready()。

Writer

Writer與字節流的OutputStream類似,也是抽象類,主要有如下方法:

public void write(int c)
public void write(char cbuf[])
abstract public void write(char cbuf[], int off, int len) throws IOException;
public void write(String str) throws IOException
public void write(String str, int off, int len)
abstract public void close() throws IOException;
abstract public void flush() throws IOException;

含義與OutputStream的對應方法基本類似,但Writer處理的單位是char,Writer還接受String類型,我們知道,String的內部就是char數組,處理時,會調用String的getChar方法先獲取char數組。

InputStreamReader/OutputStreamWriter

InputStreamReader和OutputStreamWriter是適配器類,能將InputStream/OutputStream轉換為Reader/Writer。

OutputStreamWriter

OutputStreamWriter的主要構造方法為:

public OutputStreamWriter(OutputStream out)
public OutputStreamWriter(OutputStream out, String charsetName)
public OutputStreamWriter(OutputStream out, Charset cs) 

一個重要的參數是編碼類型,可以通過名字charsetName或Charset對象傳入,如果沒有傳,則為系統默認編碼,默認編碼可以通過Charset.defaultCharset()得到。OutputStreamWriter內部有一個類型為StreamEncoder的編碼器,能將char轉換為對應編碼的字節。

我們看一段簡單的代碼,將字符串"hello, 123, 老馬"寫到文件hello.txt中,編碼格式為GB2312:

Writer writer = new OutputStreamWriter(
        new FileOutputStream("hello.txt"), "GB2312");
try{
    String str = "hello, 123, 老馬";
    writer.write(str);
}finally{
    writer.close();
}

創建一個FileOutputStream,然后將其包在一個OutputStreamWriter中,就可以直接以字符串寫入了。

InputStreamReader

InputStreamReader的主要構造方法為:

public InputStreamReader(InputStream in)
public InputStreamReader(InputStream in, String charsetName)
public InputStreamReader(InputStream in, Charset cs)

與OutputStreamWriter一樣,一個重要的參數是編碼類型。InputStreamReader內部有一個類型為StreamDecoder的解碼器,能將字節根據編碼轉換為char。

我們看一段簡單的代碼,將上面寫入的文件讀進來:

Reader reader = new InputStreamReader(
        new FileInputStream("hello.txt"), "GB2312");
try{
    char[] cbuf = new char[1024];
    int charsRead = reader.read(cbuf);
    System.out.println(new String(cbuf, 0, charsRead));
}finally{
    reader.close();
}

這段代碼假定一次read調用就讀到了所有內容,且假定長度不超過1024。為了確保讀到所有內容,可以借助待會介紹的CharArrayWriter或StringWriter。

FileReader/FileWriter

FileReader/FileWriter的輸入和目的是文件。FileReader是InputStreamReader的子類,它的主要構造方法有:

public FileReader(File file) throws FileNotFoundException
public FileReader(String fileName) throws FileNotFoundException

FileWriter是OutputStreamWriter的子類,它的主要構造方法有:

public FileWriter(File file) throws IOException
public FileWriter(File file, boolean append) throws IOException
public FileWriter(String fileName) throws IOException
public FileWriter(String fileName, boolean append) throws IOException 

append參數指定是追加還是覆蓋,如果沒傳,為覆蓋。

需要注意的是,FileReader/FileWriter不能指定編碼類型,只能使用默認編碼,如果需要指定編碼類型,可以使用InputStreamReader/OutputStreamWriter。

CharArrayReader/CharArrayWriter

CharArrayWriter

CharArrayWriter與ByteArrayOutputStream類似,它的輸出目標是char數組,這個數組的長度可以根據數據內容動態擴展。

CharArrayWriter有如下方法,可以方便的將數據轉換為char數組或字符串:

public char[] toCharArray()
public String toString()

使用CharArrayWriter,我們可以改進上面的讀文件代碼,確保將所有文件內容讀入:

Reader reader = new InputStreamReader(
        new FileInputStream("hello.txt"), "GB2312");
try{
    CharArrayWriter writer = new CharArrayWriter();
    char[] cbuf = new char[1024];
    int charsRead = 0;
    while((charsRead=reader.read(cbuf))!=-1){
        writer.write(cbuf, 0, charsRead);
    }
    System.out.println(writer.toString());
}finally{
    reader.close();
}

讀入的數據先寫入CharArrayWriter中,讀完后,再調用其toString方法獲取完整數據。

CharArrayReader

CharArrayReader與上節介紹的ByteArrayInputStream類似,它將char數組包裝為一個Reader,是一種適配器模式,它的構造方法有:

public CharArrayReader(char buf[])
public CharArrayReader(char buf[], int offset, int length) 

StringReader/StringWriter

StringReader/StringWriter與CharArrayReader/CharArrayWriter類似,只是輸入源為String,輸出目標為StringBuffer,而且,String/StringBuffer內部是由char數組組成的,所以它們本質上是一樣的。

之所以要將char數組/String與Reader/Writer進行轉換也是為了能夠方便的參與Reader/Writer構成的協作體系,復用代碼。

BufferedReader/BufferedWriter

BufferedReader/BufferedWriter是裝飾類,提供緩沖,以及按行讀寫功能。BufferedWriter的構造方法有:

public BufferedWriter(Writer out)
public BufferedWriter(Writer out, int sz)

參數sz是緩沖大小,如果沒有提供,默認為8192。它有如下方法,可以輸出平台特定的換行符:

public void newLine() throws IOException

BufferedReader的構造方法有:

public BufferedReader(Reader in)
public BufferedReader(Reader in, int sz)

參數sz是緩沖大小,如果沒有提供,默認為8192。它有如下方法,可以讀入一行:

public String readLine() throws IOException

字符'\r'或'\n'或'\r\n'被視為換行符,readLine返回一行內容,但不會包含換行符,當讀到流結尾時,返回null。

FileReader/FileWriter是沒有緩沖的,也不能按行讀寫,所以,一般應該在它們的外面包上對應的緩沖類。

我們來看個例子,還是上節介紹的學生列表,這次我們使用可讀的文本進行保存,一行保存一條學生信息,學生字段之間用逗號分隔,保存的代碼為:

public static void writeStudents(List<Student> students) throws IOException{
    BufferedWriter writer = null;
    try{
        writer = new BufferedWriter(new FileWriter("students.txt"));
        for(Student s : students){
            writer.write(s.getName()+","+s.getAge()+","+s.getScore());
            writer.newLine();
        }
    }finally{
        if(writer!=null){
            writer.close();    
        }
    }
}

保存后的文件內容顯示為:

張三,18,80.9
李四,17,67.5

從文件中讀取的代碼為:

public static List<Student> readStudents() throws IOException{
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(
                new FileReader("students.txt"));
        List<Student> students = new ArrayList<>();
        String line = reader.readLine();
        while(line!=null){
            String[] fields = line.split(",");
            Student s = new Student();
            s.setName(fields[0]);
            s.setAge(Integer.parseInt(fields[1]));
            s.setScore(Double.parseDouble(fields[2]));
            students.add(s);
            line = reader.readLine();
        }
        return students;
    }finally{
        if(reader!=null){
            reader.close();
        }
    }
}

使用readLine讀入每一行,然后使用String的方法分隔字段,再調用Integer和Double的方法將字符串轉換為int和double,這種對每一行的解析可以使用類Scanner進行簡化,待會我們介紹。

PrintWriter

PrintWriter有很多重載的print方法,如:

public void print(int i)
public void print(long l)
public void print(double d)
public void print(Object obj)

它會將這些參數轉換為其字符串形式,即調用String.valueOf(),然后再調用write。它也有很多重載形式的println方法,println除了調用對應的print,還會輸出一個換行符。除此之外,PrintWriter還有格式化輸出方法,如:

public PrintWriter printf(String format, Object ... args)

format表示格式化形式,比如,保留小數點后兩位,格式可以為:

PrintWriter writer = ...
writer.format("%.2f", 123.456f);

輸出為:

123.45

更多格式化的內容可以參看Java文檔,本文就不贅述了。

PrintWriter的方便之處在於,它有很多構造方法,可以接受文件路徑名、文件對象、OutputStream、Writer等,對於文件路徑名和File對象,還可以接受編碼類型作為參數,如下所示:

public PrintWriter(File file) throws FileNotFoundException
public PrintWriter(File file, String csn)
public PrintWriter(String fileName) throws FileNotFoundException
public PrintWriter(String fileName, String csn)
public PrintWriter(OutputStream out)
public PrintWriter(OutputStream out, boolean autoFlush)
public PrintWriter (Writer out)
public PrintWriter(Writer out, boolean autoFlush)

參數csn表示編碼類型,對於以文件對象和文件名為參數的構造方法,PrintWriter內部會構造一個BufferedWriter,比如:

public PrintWriter(String fileName) throws FileNotFoundException {
    this(new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName))),
         false);
}

對於以OutputSream為參數的構造方法,PrintWriter也會構造一個BufferedWriter,比如:

public PrintWriter(OutputStream out, boolean autoFlush) {
    this(new BufferedWriter(new OutputStreamWriter(out)), autoFlush);
    ...
}

對於以Writer為參數的構造方法,PrintWriter就不會包裝BufferedWriter了。

構造方法中的autoFlush參數表示同步緩沖區的時機,如果為true,則在調用println, printf或format方法的時候,同步緩沖區,如果沒有傳,則不會自動同步,需要根據情況調用flush方法。

可以看出,PrintWriter是一個非常方便的類,可以直接指定文件名作為參數,可以指定編碼類型,可以自動緩沖,可以自動將多種類型轉換為字符串,在輸出到文件時,可以優先選擇該類。

上面的保存學生列表代碼,使用PrintWriter,可以寫為:

public static void writeStudents(List<Student> students) throws IOException{
    PrintWriter writer = new PrintWriter("students.txt");
    try{
        for(Student s : students){
            writer.println(s.getName()+","+s.getAge()+","+s.getScore());
        }
    }finally{
        writer.close();
    }
}

PrintWriter有一個非常相似的類PrintStream,除了不能接受Writer作為構造方法外,PrintStream的其他構造方法與PrintWriter一樣,PrintStream也有幾乎一樣的重載的print和println方法,只是自動同步緩沖區的時機略有不同,在PrintStream中,只要碰到一個換行字符'\n',就會自動同步緩沖區。

PrintStream與PrintWriter的另一個區別是,雖然它們都有如下方法:

public void write(int b)

但含義是不一樣的,PrintStream只使用最低的八位,輸出一個字節,而PrintWriter是使用最低的兩位,輸出一個char。

Scanner

Scanner是一個單獨的類,它是一個簡單的文本掃描器,能夠分析基本類型和字符串,它需要一個分隔符來將不同數據區分開來,默認是使用空白符,可以通過useDelimiter方法進行指定。Scanner有很多形式的next方法,可以讀取下一個基本類型或行,如:

public float nextFloat()
public int nextInt()
public String nextLine()

Scanner也有很多構造方法,可以接受File對象、InputStream、Reader作為參數,它也可以將字符串作為參數,這時,它會創建一個StringReader,比如,以前面的解析學生記錄為例,使用Scanner,代碼可以改為:

public static List<Student> readStudents() throws IOException{
    BufferedReader reader = new BufferedReader(
            new FileReader("students.txt"));
    try{
        List<Student> students = new ArrayList<Student>();
        String line = reader.readLine();
        while(line!=null){
            Student s = new Student();
            Scanner scanner = new Scanner(line).useDelimiter(",");
            s.setName(scanner.next());
            s.setAge(scanner.nextInt());
            s.setScore(scanner.nextDouble());
            students.add(s);
            line = reader.readLine();
        }
        return students;
    }finally{
        reader.close();
    }
}

准流

我們之前一直在使用System.out向屏幕上輸出,它是一個PrintStream對象,輸出目標就是所謂的"標准"輸出,經常是屏幕。除了System.out,Java中還有兩個標准流,System.in和System.err。

System.in表示標准輸入,它是一個InputStream對象,輸入源經常是鍵盤。比如,從鍵盤接受一個整數並輸出,代碼可以為:

Scanner in = new Scanner(System.in);
int num = in.nextInt();
System.out.println(num);

System.err表示標准錯誤流,一般異常和錯誤信息輸出到這個流,它也是一個PrintStream對象,輸出目標默認與System.out一樣,一般也是屏幕。

標准流的一個重要特點是,它們可以重定向,比如可以重定向到文件,從文件中接受輸入,輸出也寫到文件中。在Java中,可以使用System類的setIn, setOut, setErr進行重定向,比如:

System.setIn(new ByteArrayInputStream("hello".getBytes("UTF-8")));
System.setOut(new PrintStream("out.txt"));
System.setErr(new PrintStream("err.txt"));

try{
    Scanner in = new Scanner(System.in);
    System.out.println(in.nextLine());
    System.out.println(in.nextLine());
}catch(Exception e){
    System.err.println(e.getMessage());
}

標准輸入重定向到了一個ByteArrayInputStream,標准輸出和錯誤重定向到了文件,所以第一次調用in.nextLine就會讀取到"hello",輸出文件out.txt中也包含該字符串,第二次調用in.nextLine會觸發異常,異常消息會寫到錯誤流中,即文件err.txt中會包含異常消息,為"No line found"。

在實際開發中,經常需要重定向標准流。比如,在一些自動化程序中,經常需要重定向標准輸入流,以從文件中接受參數,自動執行,避免人手工輸入。在后台運行的程序中,一般都需要重定向標准輸出和錯誤流到日志文件,以記錄和分析運行的狀態和問題。

在Linux系統中,標准輸入輸出流也是一種重要的協作機制。很多命令都很小,只完成單一功能,實際完成一項工作經常需要組合使用多個命令,它們協作的模式就是通過標准輸入輸出流,每個命令都可以從標准輸入接受參數,處理結果寫到標准輸出,這個標准輸出可以連接到下一個命令作為標准輸入,構成管道式的處理鏈條。比如,查找一個日志文件access.log中"127.0.0.1"出現的行數,可以使用命令:

cat access.log | grep 127.0.0.1 | wc -l

有三個程序cat, grep, wc,|是管道符號,它將cat的標准輸出重定向為了grep的標准輸入,而grep的標准輸出又成了wc的標准輸入。

實用方法

可以看出,字符流也包含了很多的類,雖然很靈活,但對於一些簡單的需求,卻需要寫很多代碼,實際開發中,經常需要將一些常用功能進行封裝,提供更為簡單的接口。下面我們提供一些實用方法,以供參考。

拷貝

拷貝Reader到Writer,代碼為:

public static void copy(final Reader input,
        final Writer output) throws IOException {
    char[] buf = new char[4096];
    int charsRead = 0;
    while ((charsRead = input.read(buf)) != -1) {
        output.write(buf, 0, charsRead);
    }
}

將文件全部內容讀入到一個字符串

參數為文件名和編碼類型,代碼為:

public static String readFileToString(final String fileName,
        final String encoding) throws IOException{
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(new InputStreamReader(
                new FileInputStream(fileName), encoding));
        StringWriter writer = new StringWriter();
        copy(reader, writer);
        return writer.toString();
    }finally{
        if(reader!=null){
            reader.close();
        }
    }
}

這個方法利用了StringWriter,並調用了上面的拷貝方法。

將字符串寫到文件

參數為文件名、字符串內容和編碼類型,代碼為:

public static void writeStringToFile(final String fileName,
        final String data, final String encoding) throws IOException {
    Writer writer = null;
    try{
        writer = new OutputStreamWriter(new FileOutputStream(fileName), encoding);
        writer.write(data);
    }finally{
        if(writer!=null){
            writer.close();
        }
    }
}

按行將多行數據寫到文件

參數為文件名、編碼類型、行的集合,代碼為:

public static void writeLines(final String fileName,
        final String encoding, final Collection<?> lines) throws IOException {
    PrintWriter writer = null;
    try{
        writer = new PrintWriter(fileName, encoding);
        for(Object line : lines){
            writer.println(line);
        }
    }finally{
        if(writer!=null){
            writer.close();
        }
    }
}

按行將文件內容讀到一個列表中

參數為文件名、編碼類型,代碼為:

public static List<String> readLines(final String fileName,
        final String encoding) throws IOException{
    BufferedReader reader = null;
    try{
        reader = new BufferedReader(new InputStreamReader(
                new FileInputStream(fileName), encoding));
        List<String> list = new ArrayList<>();
        String line = reader.readLine();
        while(line!=null){
            list.add(line);
            line = reader.readLine();
        }
        return list;
    }finally{
        if(reader!=null){
            reader.close();
        }
    }
}

Apache有一個類庫Commons IO,里面提供了很多簡單易用的方法,實際開發中,可以考慮使用。

小結

本節我們介紹了如何在Java中以字符流的方式讀寫文本文件,我們強調了二進制思維、文本文本與二進制文件的區別、編碼、以及字符流與字節流的不同,我們介紹了個各種字符流、Scanner以及標准流,最后總結了一些實用方法。

寫文件時,可以優先考慮PrintWriter,因為它使用方便,支持自動緩沖、支持指定編碼類型、支持類型轉換等。讀文件時,如果需要指定編碼類型,需要使用InputStreamReader,不需要,可使用FileReader,但都應該考慮在外面包上緩沖類BufferedReader。

通過上節和本節,我們應該可以從容的讀寫文件內容了,但文件本身的操作,如查看元數據信息、重命名、刪除,目錄的操作,如遍歷文件、查找文件、新建目錄等,又該如何進行呢?讓我們下節繼續探索。

----------------

未完待續,查看最新文章,敬請關注微信公眾號“老馬說編程”(掃描下方二維碼),從入門到高級,深入淺出,老馬和你一起探索Java編程及計算機技術的本質。用心原創,保留所有版權。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM