13.0 第13章 文件基本技术 13.1 文件概述

第13章 文件基本技术

我们在日常计算机操作中,接触和处理最多的,除了上网,大概就是各种各样的文件了,从本章开始,我们就来探讨文件处理。文件处理的内容比较多,我们先在13.1节进行概述,并介绍后续章节的安排。

13.1 文件概述

在本节,我们主要介绍文件有关的一些基本概念和常识,Java中处理文件的基本思路和类结构,以及接下来的章节安排。

13.1.1 基本概念和常识

下面,我们先介绍一些基本概念和常识,包括二进制思维、文件类型、文本文件的编码、文件系统和文件读写等。

1.二进制思维

为了透彻理解文件,我们首先要有一个二进制思维。所有文件,不论是可执行文件、图片文件、视频文件、Word文件、压缩文件、txt文件,都没什么可神秘的,它们都是以0和1的二进制形式保存的。我们所看到的图片、视频、文本,都是应用程序对这些二进制的解析结果。

作为程序员,我们应该有一个编辑器,能查看文件的二进制形式,比如UltraEdit,它支持以十六进制进行查看和编辑。比如,一个文本文件,看到的内容为:

1
hello, 123, 老马

打开十六进制编辑,看到的内容如图13-1所示。

epub_923038_113

图13-1 使用UltraEdit查看十六进制

左边的部分就是其对应的十六进制,”hello”对应的十六进制是”68 65 6C 6C 6F”,对应ASCII码编号”104 101 108 108 111”, “马”对应的十六进制是”E9 A9 AC”,这是”马”的UTF-8编码。

2.文件类型

虽然所有数据都是以二进制形式保存的,但为了方便处理数据,高级语言引入了数据类型的概念。文件处理也类似,所有文件都是以二进制形式保存的,但为了便于理解和处理文件,文件也有文件类型的概念。

文件类型通常以扩展名的形式体现,比如,PDF文件类型的扩展名是.pdf,图片文件的一种常见扩展名是.jpg,压缩文件的一种常见扩展名是.zip。每种文件类型都有一定的格式,代表着文件含义和二进制之间的映射关系。比如一个Word文件,其中有文本、图片、表格,文本可能有颜色、字体、字号等,doc文件类型就定义了这些内容和二进制表示之间的映射关系。有的文件类型的格式是公开的,有的可能是私有的,我们也可以定义自己私有的文件格式。

对于一种文件类型,往往有一种或多种应用程序可以解读它,进行查看和编辑,一个应用程序往往可以解读一种或多种文件类型。在操作系统中,一种扩展名往往关联一个应用程序,比如.doc后缀关联Word应用。用户通过双击试图打开某扩展名的文件时,操作系统查找关联的应用程序,启动该程序,传递该文件路径给它,程序再打开该文件。

需要说明的是,给文件加正确的扩展名是一种惯例,但并不是强制的,如果扩展名和文件类型不匹配,应用程序试图打开该文件时可能会报错。另外,一个文件可以选择使用多种应用程序进行解读,在操作系统中,一般通过右键单击文件,选择打开方式即可。

文件类型可以粗略分为两类:一类是文本文件;另一类是二进制文件。文本文件的例子有普通的文本文件(.txt),程序源代码文件(.java)、HTML文件(.html)等;二进制文件的例子有压缩文件(.zip)、PDF文件(.pdf)、MP3文件(.mp3)、Excel文件(.xlsx)等。

基本上,文本文件里的每个二进制字节都是某个可打印字符的一部分,都可以用最基本的文本编辑器进行查看和编辑,如Windows上的notepad、Linux上的vi。二进制文件中,每个字节就不一定表示字符,可能表示颜色、字体、声音大小等,如果用基本的文本编辑器打开,一般都是满屏的乱码,需要专门的应用程序进行查看和编辑。

3.文本文件的编码

对于文本文件,我们还必须注意文件的编码方式。文本文件中包含的基本都是可打印字符,但字符到二进制的映射(即编码)却有多种方式,如GB18030、UTF-8,我们在第2章详细介绍过各种编码,这里就不赘述了。

对于一个给定的文本文件,它采用的是什么编码方式呢?一般而言,我们是不知道的。那应用程序用什么编码方式进行解读呢?一般使用某种默认的编码方式,可能是应用程序默认的,也可能是操作系统默认的,当然也可能采用一些比较智能的算法自动推断编码方式。

对于UTF-8编码的文件,我们需要特别说明。有一种方式,可以标记该文件是UTF-8编码的,那就是在文件最开头加入三个特殊字节(0xEF 0xBB 0xBF),这三个特殊字节被称为BOM头,BOM是Byte Order Mark(即字节序标记)的缩写。比如,对前面的hello.txt文件,带BOM头的UTF-8编码的十六进制形式如图13-2所示。

epub_923038_114

图13-2 带BOM头的文件

图13-1和图13-2所示都是UTF-8编码,看到的字符内容也一样,但二进制内容不一样,一个带BOM头,一个不带BOM头。

需要注意的是,不是所有应用程序都支持带BOM头的UTF-8编码文件,比如PHP就不支持BOM,如果PHP源代码文件带BOM头,PHP运行就会出错。碰到这种问题时,前面介绍的二进制思维就特别重要,不要只看文件的显示,还要看文件背后的二进制

另外,我们需要说明下文本文件的换行符。在Windows系统中,换行符一般是两个字符”\r\n”,即ASCII码的13(’\r’)和10(’\n’),在Linux系统中,换行符一般是一个字符”\n”。

4.文件系统

文件一般是放在硬盘上的,一个机器上可能有多个硬盘,但各种操作系统都会隐藏物理硬盘概念,提供一个逻辑上的统一结构。在Windows中,可以有多个逻辑盘,如C、D、E等,每个盘可以被格式化为一种不同的文件系统,常见的文件系统有FAT32和NTFS。在Linux中,只有一个逻辑的根目录,用斜线/表示。Linux支持多种不同的文件系统,如Ext2/Ext3/Ext4等。不同的文件系统有不同的文件组织方式、结构和特点,不过,一般编程时,语言和类库为我们提供了统一的API,我们并不需要关心其细节。

在逻辑上,Windows中有多个根目录,Linux中有一个根目录,每个根目录下有一棵子目录和文件构成的树。每个文件都有文件路径的概念,路径有两种形式:一种是绝对路径,另一种是相对路径

所谓绝对路径,是从根目录开始到当前文件的完整路径,在Windows中,目录之间用反斜线分隔,如C:\code\hello.java,在Linux中,目录之间用斜线分隔,如/Users/laoma/Desktop/code/hello.java。在Java中,java.io.File类定义了一个静态变量File.separator,表示路径分隔符,编程时应使用该变量而避免硬编码。

所谓相对路径,是相对于当前目录而言的。在命令行终端上,通过cd命令进入的目录就是当前目录;在Java中,通过System.getProperty(“user.dir”)可以得到运行Java程序的当前目录。相对路径不以根目录开头,比如在Windows上,当前目录为D:\laoma,相对路径为code\hello.java,则完整路径为D:\laoma\code\hello.java。

每个文件除了有具体内容,还有元数据信息,如文件名、创建时间、修改时间、文件大小等。文件还有一个是否隐藏的性质。在Linux系统中,如果文件名以.开头,则为隐藏文件;在Windows系统中,隐藏是文件的一个属性,可以进行设置。

大部分文件系统的文件和目录具有访问权限的概念,对所有者、用户组可以有不同的权限,具体权限包括读、写、执行。

文件名有大小写是否敏感的概念。在Windows系统中,一般是大小写不敏感的,而Linux则一般是大小写敏感的。也就是说,同一个目录下,abc.txt和ABC.txt在Windows中被视为同一个文件,而在Linux中则被视为不同的文件。

操作系统中有一个临时文件的概念。临时文件位于一个特定目录,比如Windows7中,临时文件一般位于“C:\Users\用户名\AppData\Local\Temp”; Linux系统中,临时文件位于/tmp。操作系统会有一定的策略自动清理不用的临时文件。临时文件一般不是用户手工创建的,而是应用程序产生的,用于临时目的。

5.文件读写

文件是放在硬盘上的,程序处理文件需要将文件读入内存,修改后,需要写回硬盘。操作系统提供了对文件读写的基本API,不同操作系统的接口和实现是不一样的,不过,有一些共同的概念。Java封装了操作系统的功能,提供了统一的API。

一个基本常识是:硬盘的访问延时,相比内存,是很慢的。操作系统和硬盘一般是按块批量传输,而不是按字节,以摊销延时开销,块大小一般至少为512字节,即使应用程序只需要文件的一个字节,操作系统也会至少将一个块读进来。一般而言,应尽量减少接触硬盘,接触一次,就一次多做一些事情。对于网络请求和其他输入输出设备,原则都是类似的。

另一个基本常识是:一般读写文件需要两次数据复制,比如读文件,需要先从硬盘复制到操作系统内核,再从内核复制到应用程序分配的内存中。操作系统运行所在的环境和应用程序是不一样的,操作系统所在的环境是内核态,应用程序是用户态,应用程序调用操作系统的功能,需要两次环境的切换,先从用户态切到内核态,再从内核态切到用户态。这种用户态/内核态的切换是有开销的,应尽量减少这种切换

为了提升文件操作的效率,应用程序经常使用一种常见的策略,即使用缓冲区。读文件时,即使目前只需要少量内容,但预知还会接着读取,就一次读取比较多的内容,放到读缓冲区,下次读取时,如果缓冲区有,就直接从缓冲区读,减少访问操作系统和硬盘。写文件时,先写到写缓冲区,写缓冲区满了之后,再一次性调用操作系统写到硬盘。不过,需要注意的是,在写结束的时候,要记住将缓冲区的剩余内容同步到硬盘。操作系统自身也会使用缓冲区,不过,应用程序更了解读写模式,恰当使用往往可以有更高的效率。

操作系统操作文件一般有打开和关闭的概念。打开文件会在操作系统内核建立一个有关该文件的内存结构,这个结构一般通过一个整数索引来引用,这个索引一般称为文件描述符。这个结构是消耗内存的,操作系统能同时打开的文件一般也是有限的,在不用文件的时候,应该记住关闭文件。关闭文件一般会同步缓冲区内容到硬盘,并释放占据的内存结构。

操作系统一般支持一种称为内存映射文件的高效的随机读写大文件的方法,将文件直接映射到内存,操作内存就是操作文件。在内存映射文件中,只有访问到的数据才会被实际复制到内存,且数据只会复制一次,被操作系统以及多个应用程序共享。

13.1.2 Java文件概述

在Java中处理文件有一些基本概念和类,包括流、装饰器设计模式、Reader/Writer、随机读写文件、File、NIO、序列化和反序列化,下面分别介绍。

1.流

在Java中(很多其他语言也类似),文件一般不是单独处理的,而是视为输入输出(Input/Output, IO)设备的一种。Java使用基本统一的概念处理所有的IO,包括键盘、显示终端、网络等。

这个统一的概念是,流有输入流输出流之分。输入流就是可以从中获取数据,输入流的实际提供者可以是键盘、文件、网络等;输出流就是可以向其中写入数据,输出流的实际目的地可以是显示终端、文件、网络等。

Java IO的基本类大多位于包java.io中。类InputStream表示输入流,OutputStream表示输出流,而FileInputStream表示文件输入流,FileOutputStream表示文件输出流。

有了流的概念,就有了很多面向流的代码,比如对流做加密、压缩、计算信息摘要、计算检验和等,这些代码接受的参数和返回结果都是抽象的流,它们构成了一个协作体系,这类似于之前介绍的接口概念、面向接口的编程,以及容器类协作体系。一些实际上不是IO的数据源和目的地也转换为了流,以方便参与这种协作,比如字节数组,也包装为了流ByteArrayInputStream和ByteArrayOutputStream。

2.装饰器设计模式

基本的流按字节读写,没有缓冲区,这不方便使用。Java解决这个问题的方法是使用装饰器设计模式,引入了很多装饰类,对基本的流增加功能,以方便使用。一般一个类只关注一个方面,实际使用时,经常会需要多个装饰类。

Java中有很多装饰类,有两个基类:过滤器输入流FilterInputStream和过滤器输出流FilterOutputStream。过滤类似于自来水管道,流入的是水,流出的也是水,功能不变,或者只是增加功能。它有很多子类,这里列举一些:
1)对流起缓冲装饰的子类是BufferedInputStream和BufferedOutputStream。
2)可以按8种基本类型和字符串对流进行读写的子类是DataInputStream和DataOutput-Stream。
3)可以对流进行压缩和解压缩的子类有GZIPInputStream、ZipInputStream、GZIPOutput-Stream和ZipOutputStream。
4)可以将基本类型、对象输出为其字符串表示的子类有PrintStream。

众多的装饰类使得整个类结构变得比较复杂,完成基本的操作也需要比较多的代码;其优点是非常灵活,在解决某些问题时也很优雅。

3. Reader/Writer

以InputStream/OutputStream为基类的流基本都是以二进制形式处理数据的,不能够方便地处理文本文件,没有编码的概念,能够方便地按字符处理文本数据的基类是Reader和Writer,它也有很多子类:
1)读写文件的子类是FileReader和FileWriter。
2)起缓冲装饰的子类是BufferedReader和BufferedWriter。
3)将字符数组包装为Reader/Writer的子类是CharArrayReader和CharArrayWriter。
4)将字符串包装为Reader/Writer的子类是StringReader和StringWriter。
5)将InputStream/OutputStream转换为Reader/Writer的子类是InputStreamReader和OutputStreamWriter。
6)将基本类型、对象输出为其字符串表示的子类是PrintWriter。

4.随机读写文件

大部分情况下,使用流或Reader/Writer读写文件内容,但Java提供了一个独立的可以随机读写文件的类RandomAccessFile,适用于大小已知的记录组成的文件。该类在日常应用开发中用得比较少,但在一些系统程序中用得比较多。

5. File

上面介绍的都是操作数据本身,而关于文件路径、文件元数据、文件目录、临时文件、访问权限管理等,Java使用File这个类来表示。

6. NIO

以上介绍的类基本都位于包java.io下,Java还有一个关于IO操作的包java.nio, nio表示New IO,这个包下同样包含大量的类。

NIO代表一种不同的看待IO的方式,它有缓冲区和通道的概念。利用缓冲区和通道往往可以达成和流类似的目的,不过,它们更接近操作系统的概念,某些操作的性能也更高。比如,复制文件到网络,通道可以利用操作系统和硬件提供的DMA机制(Direct Memory Access,直接内存存取),不用CPU和应用程序参与,直接将数据从硬盘复制到网卡。

除了看待方式不同,NIO还支持一些比较底层的功能,如内存映射文件、文件加锁、自定义文件系统、非阻塞式IO、异步IO等

不过,这些功能要么是比较底层,普通应用程序用到得比较少,要么主要适用于网络IO操作,我们大多不会介绍,只会介绍内存映射文件。

7.序列化和反序列化

简单来说,序列化就是将内存中的Java对象持久保存到一个流中,反序列化就是从流中恢复Java对象到内存。序列化和反序列化主要有两个用处:一是对象状态持久化,二是网络远程调用,用于传递和返回对象。

Java主要通过接口Serializable和类ObjectInputStream/ObjectOutputStream提供对序列化的支持,基本的使用是比较简单的,但也有一些复杂的地方。不过,Java的默认序列化有一些缺点,比如,序列化后的形式比较大、浪费空间,序列化/反序列化的性能也比较低,更重要的问题是,它是Java特有的技术,不能与其他语言交互。

XML是前几年最为流行的描述结构性数据的语言和格式,Java对象也可以序列化为XML格式。XML容易阅读和编辑,且可以方便地与其他语言进行交互。XML强调格式化但比较“笨重”, JSON是近几年来逐渐流行的轻量级的数据交换格式,在很多场合替代了XML,也非常容易阅读和编辑。Java对象也可以序列化为JSON格式,且与其他语言进行交互。

XML和JSON都是文本格式,人容易阅读,但占用的空间相对大一些,在只用于网络远程调用的情况下,有很多流行的、跨语言的、精简且高效的对象序列化机制,如ProtoBuf、Thrift、MessagePack等。其中,MessagePack是二进制形式的JSON,更小更快。

文件看起来是一件非常简单的事情,但实际却没有那么简单,Java的设计也不是太完美,包含了大量的类,这使得对于文件的理解变得困难。为便于理解,我们将采用以下思路在接下来的章节中进行探讨。

首先,我们介绍如何处理二进制文件,或者将所有文件看作二进制,介绍如何操作,对于常见操作,我们会封装,提供一些简单易用的方法。下一步,我们介绍如何处理文本文件,我们会考虑编码、按行处理等,同样,对于常见操作,我们会封装,提供简单易用的方法。接下来,我们介绍文件本身和目录操作File类,我们也会封装常见操作。以上这些内容是文件处理的基本技术,我们会在本章进行讨论。

在日常编程中,我们经常会需要处理一些具体类型的文件,如属性文件、CSV文件、Excel文件、HTML文件和压缩文件,直接使用字节流/字符流来处理一般是很不方便的,往往有一些更为高层的API,关于这些,我们下章介绍。此外,下章还会介绍比较底层的对文件的操作RandomAccessFile类、内存映射文件,以及序列化。文件看上去应该很简单,但实际却包含很多内容,让我们耐住性子,下一节,先从二进制开始。