14.2 随机读写文件
14.2 随机读写文件
我们先介绍RandomAccessFile的用法,然后介绍怎么利用它实现一个简单的键值对数据库。
14.2.1 用法
RandomAccessFile有如下构造方法:
1 | public RandomAccessFile(String name, String mode) |
参数name和file容易理解,表示文件路径和File对象,mode是什么意思呢?它表示打开模式,可以有4个取值。
1)”r”:只用于读。
2)”rw”:用于读和写。
3)”rws”:和”rw”一样,用于读和写,另外,它要求文件内容和元数据的任何更新都同步到设备上。
4)”rwd”:和”rw”一样,用于读和写,另外,它要求文件内容的任何更新都同步到设备上,和”rws”的区别是,元数据的更新不要求同步。
RandomAccessFile虽然不是InputStream/OutputStream的子类,但它也有类似于读写字节流的方法。另外,它还实现了DataInput/DataOutput接口。这些方法我们之前基本都介绍过,这里列举部分方法,以增强直观感受:
1 | //读一个字节,取最低8位,0~255 |
RandomAccessFile还有另外两个read方法:
1 | public final void readFully(byte b[]) throws IOException |
与对应的read方法的区别是,它们可以确保读够期望的长度,如果到了文件结尾也没读够,它们会抛出EOFException异常。
RandomAccessFile内部有一个文件指针,指向当前读写的位置,各种read/write操作都会自动更新该指针。与流不同的是,RandomAccessFile可以获取该指针,也可以更改该指针,相关方法是:
1 | //获取当前文件指针 |
RandomAccessFile是通过本地方法,最终调用操作系统的API来实现文件指针调整的。
InputStream有一个skip方法,可以跳过输入流中n个字节,默认情况下,它是通过实际读取n个字节实现的。RandomAccessFile有一个类似方法,不过它是通过更改文件指针实现的:
1 | public int skipBytes(int n) throws IOException |
RandomAccessFile可以直接获取文件长度,返回文件字节数,方法为:
1 | public native long length() throws IOException |
它还可以直接修改文件长度,方法为:
1 | public native void setLength(long newLength) throws IOException |
如果当前文件的长度小于newLength,则文件会扩展,扩展部分的内容未定义。如果当前文件的长度大于newLength,则文件会收缩,多出的部分会截取,如果当前文件指针比newLength大,则调用后会变为newLength。
RandomAccessFile中有如下方法,需要注意一下:
1 | public final void writeBytes(String s) throws IOException |
看上去,writeBytes方法可以直接写入字符串,而readLine方法可以按行读入字符串,实际上,这两个方法都是有问题的,它们都没有编码的概念,都假定一个字节就代表一个字符,这对于中文显然是不成立的,所以,应避免使用这两个方法。
14.2.2 设计一个键值数据库BasicDB
在日常的一般文件读写中,使用流就可以了,但在一些系统程序中,流是不适合的, RandomAccessFile因为更接近操作系统,更为方便和高效。
下面,我们来看怎么利用RandomAccessFile实现一个简单的键值数据库,我们称之为BasicDB。我们从功能、接口、使用和设计等几个方面进行介绍,完整的代码在github上,地址为 https://github.com/swiftma/program-logic ,位于包shuo.laoma.file.c60下。
1.功能
BasicDB提供的接口类似于Map接口,可以按键保存、查找、删除,但数据可以持久化保存到文件上。此外,不像HashMap/TreeMap,它们将所有数据保存在内存,BasicDB只把元数据如索引信息保存在内存,值的数据保存在文件上。相比HashMap/TreeMap, BasicDB的内存消耗可以大大降低,存储的键值对个数大大提高,尤其当值数据比较大的时候。BasicDB通过索引,以及RandomAccessFile的随机读写功能保证效率。
2.接口
对外,BasicDB提供的构造方法是:
1 | public BasicDB(String path, String name) throws IOException |
path表示数据库文件所在的目录,该目录必须已存在。name表示数据库的名称,BasicDB会使用以name开头的两个文件,一个存储元数据,扩展名是.meta,一个存储键值对中的值数据,扩展名是.data。比如,如果name为student,则两个文件为student.meta和student.data,这两个文件不一定存在,如果不存在,则创建新的数据库,如果已存在,则加载已有的数据库。
BasicDB提供的公开方法有:
1 | //保存键值对,键为String类型,值为byte数组 |
为便于实现,我们假定值即byte数组的长度不超过1020,如果超过,会抛出异常,当然,这个长度在代码中可以调整。在调用put和remove后,修改不会马上反映到文件中,如果需要确保保存到文件中,需要调用flush。
3.使用
在BasicDB中,我们设计的值为byte数组,这看上去是一个限制,不便使用,我们主要是为了简化,而且任何数据都可以转化为byte数组保存。对于字符串,可以使用getBytes()方法,对于对象,可以使用之前介绍的流转换为byte数组。
比如,保存一些学生信息到数据库,代码可以为:
1 | private static byte[] toBytes(Student student) throws IOException { |
保存学生信息到当前目录下的students数据库,toBytes方法将Student转换为了字节。14.3节会介绍序列化,使用序列化,toBytes方法的代码可以更为简洁。
4.设计
我们采用如下简单的设计。
1)将键值对分为两部分,值保存在单独的.data文件中,值在.data文件中的位置和键称为索引,索引保存在.meta文件中。
2)在.data文件中,每个值占用的空间固定,固定长度为1024,前4个字节表示实际长度,然后是实际内容,实际长度不够1020的,后面是补白字节0。
3)索引信息既保存在.meta文件中,也保存在内存中,在初始化时,全部读入内存,对索引的更新不立即更新文件,调用flush方法才更新。
4)删除键值对不修改.data文件,但会从索引中删除并记录空白空间,下次添加键值对的时候会重用空白空间,所有的空白空间也记录到.meta文件中。
我们暂不考虑由于并发访问、异常关闭等引起的一致性问题。这个设计虽然是比较粗糙的,但可以演示一些基本概念。
14.2.3 BasicDB的实现
下面,我们来看实现代码,先来看内部组成和构造方法,然后看一些主要方法的实现。
BasicDB定义了如下静态变量:
1 | private static final int MAX_DATA_LENGTH = 1020; |
内存中表示索引和空白空间的数据结构是:
1 | Map<String, Long> indexMap; //索引信息,键->值在.data文件中的位置 |
表示文件的数据结构是:
1 | RandomAccessFile db; //值数据文件 |
构造方法的代码为:
1 | public BasicDB(String path, String name) throws IOException{ |
元数据文件存在时,会调用loadMeta将元数据加载到内存,我们先假定不存在,先来看其他代码。保存键值对的方法是put,其代码为:
1 | public void put(String key, byte[] value) throws IOException{ |
先通过索引查找键是否存在,如果不存在,调用nextAvailablePos方法为值找一个存储位置,并将键和存储位置保存到索引中,最后,调用writeData方法将值写到数据文件中。
nextAvailablePos的代码是:
1 | private long nextAvailablePos() throws IOException{ |
它首先查找空白空间,如果有,则重用,否则定位到文件末尾。
writeData方法实际写值数据,它的代码是:
1 | private void writeData(long pos, byte[] data) throws IOException { |
它先检查长度,长度满足的情况下,定位到指定位置,写实际数据的长度、写内容、最后补白。
可以看出,在这个实现中,索引信息和空白空间信息并没有实时保存到文件中,要保存,需要调用flush方法,待会我们再看这个方法。
根据键获取值的方法是get,其代码为:
1 | public byte[] get(String key) throws IOException{ |
如果键存在,就调用getData方法获取数据。getData方法的代码为:
1 | private byte[] getData(long pos) throws IOException{ |
代码也很简单,定位到指定位置,读取实际长度,然后调用readFully方法读够内容。
删除键值对的方法是remove,其代码为:
1 | public void remove(String key){ |
从索引结构中删除,并添加到空白空间队列中。
同步元数据的方法是flush(),其代码为:
1 | public void flush() throws IOException{ |
回顾一下,getFD方法会返回文件描述符,其sync方法会确保文件内容保存到设备上, saveMeta方法的代码为:
1 | private void saveMeta() throws IOException{ |
索引信息和空白空间保存在一个文件中,saveIndex保存索引信息,代码为:
1 | private void saveIndex(DataOutputStream out) throws IOException{ |
先保存键值对个数,然后针对每条索引信息,保存键及值在.data文件中的位置。
saveGaps方法保存空白空间信息,代码为:
1 | private void saveGaps(DataOutputStream out) throws IOException{ |
也是先保存长度,然后保存每条空白空间信息。
我们使用了之前介绍的流来保存,这些代码比较烦琐,如果使用后续介绍的序列化,代码会更为简洁。
在构造方法中,我们提到了loadMeta方法,它是saveMeta的逆操作,代码为:
1 | private void loadMeta() throws IOException{ |
loadIndex加载索引,代码为:
1 | private void loadIndex(DataInputStream in) throws IOException{ |
loadGaps加载空白空间,代码为:
1 | private void loadGaps(DataInputStream in) throws IOException{ |
数据库关闭的代码为:
1 | public void close() throws IOException{ |
就是同步数据,并关闭数据文件。
14.2.4 小结
本节介绍了RandomAccessFile的用法,它可以随机读写,更为接近操作系统的API,在实现一些系统程序时,它比流要更为方便高效。利用RandomAccessFile,我们实现了一个非常简单的键值对数据库,我们演示了这个数据库的用法、接口、设计和实现代码。在这个例子中,我们同时展示了之前介绍的容器和流的一些用法。
这个数据库虽然简单粗糙,但也具备了一些优良特点,比如占用的内存空间比较小,可以存储大量键值对,可以根据键高效访问值等。