14.2 随机读写文件

14.2 随机读写文件

我们先介绍RandomAccessFile的用法,然后介绍怎么利用它实现一个简单的键值对数据库。

14.2.1 用法

RandomAccessFile有如下构造方法:

1
2
3
4
public RandomAccessFile(String name, String mode)
throws FileNotFoundException
public RandomAccessFile(File file, String mode)
throws FileNotFoundException

参数name和file容易理解,表示文件路径和File对象,mode是什么意思呢?它表示打开模式,可以有4个取值。
1)”r”:只用于读。
2)”rw”:用于读和写。
3)”rws”:和”rw”一样,用于读和写,另外,它要求文件内容和元数据的任何更新都同步到设备上。
4)”rwd”:和”rw”一样,用于读和写,另外,它要求文件内容的任何更新都同步到设备上,和”rws”的区别是,元数据的更新不要求同步。

RandomAccessFile虽然不是InputStream/OutputStream的子类,但它也有类似于读写字节流的方法。另外,它还实现了DataInput/DataOutput接口。这些方法我们之前基本都介绍过,这里列举部分方法,以增强直观感受:

1
2
3
4
5
6
//读一个字节,取最低8位,0~255
public int read() throws IOException
public int read(byte b[]) throws IOException
public final int readInt() throws IOException
public final void writeInt(int v) throws IOException
public void write(byte b[]) throws IOException

RandomAccessFile还有另外两个read方法:

1
2
public final void readFully(byte b[]) throws IOException
public final void readFully(byte b[], int off, int len) throws IOException

与对应的read方法的区别是,它们可以确保读够期望的长度,如果到了文件结尾也没读够,它们会抛出EOFException异常。

RandomAccessFile内部有一个文件指针,指向当前读写的位置,各种read/write操作都会自动更新该指针。与流不同的是,RandomAccessFile可以获取该指针,也可以更改该指针,相关方法是:

1
2
3
4
//获取当前文件指针
public native long getFilePointer() throws IOException
//更改当前文件指针到pos
public native void seek(long pos) throws IOException

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
2
public final void writeBytes(String s) throws IOException
public final String readLine() 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
2
3
4
5
6
7
//保存键值对,键为String类型,值为byte数组
public void put(String key, byte[] value) throws IOException
//根据键获取值,如果键不存在,返回null
public byte[] get(String key) throws IOException
public void remove(String key) //根据键删除
public void flush() throws IOException //确保将所有数据保存到文件
public void close() throws IOException //关闭数据库

为便于实现,我们假定值即byte数组的长度不超过1020,如果超过,会抛出异常,当然,这个长度在代码中可以调整。在调用put和remove后,修改不会马上反映到文件中,如果需要确保保存到文件中,需要调用flush。

3.使用

在BasicDB中,我们设计的值为byte数组,这看上去是一个限制,不便使用,我们主要是为了简化,而且任何数据都可以转化为byte数组保存。对于字符串,可以使用getBytes()方法,对于对象,可以使用之前介绍的流转换为byte数组。

比如,保存一些学生信息到数据库,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static byte[] toBytes(Student student) throws IOException {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream dout = new DataOutputStream(bout);
dout.writeUTF(student.getName());
dout.writeInt(student.getAge());
dout.writeDouble(student.getScore());
return bout.toByteArray();
}
public static void saveStudents(Map<String, Student> students)
throws IOException {
BasicDB db = new BasicDB("./", "students");
for(Map.Entry<String, Student> kv : students.entrySet()) {
db.put(kv.getKey(), toBytes(kv.getValue()));
}
db.close();
}

保存学生信息到当前目录下的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
2
3
4
5
6
7
private static final int MAX_DATA_LENGTH = 1020;
//补白字节
private static final byte[] ZERO_BYTES = new byte[MAX_DATA_LENGTH];
//数据文件扩展名
private static final String DATA_SUFFIX = ".data";
//元数据文件扩展名,包括索引和空白空间数据
private static final String META_SUFFIX = ".meta";

内存中表示索引和空白空间的数据结构是:

1
2
Map<String, Long> indexMap; //索引信息,键->值在.data文件中的位置
Queue<Long> gaps; //空白空间,值为在.data文件中的位置

表示文件的数据结构是:

1
2
RandomAccessFile db; //值数据文件
File metaFile; //元数据文件

构造方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
public BasicDB(String path, String name) throws IOException{
File dataFile = new File(path + name + DATA_SUFFIX);
metaFile = new File(path + name + META_SUFFIX);
db = new RandomAccessFile(dataFile, "rw");
if(metaFile.exists()){
loadMeta();
}else{
indexMap = new HashMap<>();
gaps = new ArrayDeque<>();
}
}

元数据文件存在时,会调用loadMeta将元数据加载到内存,我们先假定不存在,先来看其他代码。保存键值对的方法是put,其代码为:

1
2
3
4
5
6
7
8
public void put(String key, byte[] value) throws IOException{
Long index = indexMap.get(key);
if(index==null){
index = nextAvailablePos();
indexMap.put(key, index);
}
writeData(index, value);
}

先通过索引查找键是否存在,如果不存在,调用nextAvailablePos方法为值找一个存储位置,并将键和存储位置保存到索引中,最后,调用writeData方法将值写到数据文件中。

nextAvailablePos的代码是:

1
2
3
4
5
6
7
private long nextAvailablePos() throws IOException{
if(! gaps.isEmpty()){
return gaps.poll();
}else{
return db.length();
}
}

它首先查找空白空间,如果有,则重用,否则定位到文件末尾。

writeData方法实际写值数据,它的代码是:

1
2
3
4
5
6
7
8
9
10
private void writeData(long pos, byte[] data) throws IOException {
if(data.length > MAX_DATA_LENGTH) {
throw new IllegalArgumentException("maximum allowed length is "
+ MAX_DATA_LENGTH + ", data length is " + data.length);
}
db.seek(pos);
db.writeInt(data.length);
db.write(data);
db.write(ZERO_BYTES, 0, MAX_DATA_LENGTH - data.length);
}

它先检查长度,长度满足的情况下,定位到指定位置,写实际数据的长度、写内容、最后补白。

可以看出,在这个实现中,索引信息和空白空间信息并没有实时保存到文件中,要保存,需要调用flush方法,待会我们再看这个方法。

根据键获取值的方法是get,其代码为:

1
2
3
4
5
6
7
public byte[] get(String key) throws IOException{
Long index = indexMap.get(key);
if(index! =null){
return getData(index);
}
return null;
}

如果键存在,就调用getData方法获取数据。getData方法的代码为:

1
2
3
4
5
6
7
private byte[] getData(long pos) throws IOException{
db.seek(pos);
int length = db.readInt();
byte[] data = new byte[length];
db.readFully(data);
return data;
}

代码也很简单,定位到指定位置,读取实际长度,然后调用readFully方法读够内容。

删除键值对的方法是remove,其代码为:

1
2
3
4
5
6
public void remove(String key){
Long index = indexMap.remove(key);
if(index! =null){
gaps.offer(index);
}
}

从索引结构中删除,并添加到空白空间队列中。

同步元数据的方法是flush(),其代码为:

1
2
3
4
public void flush() throws IOException{
saveMeta();
db.getFD().sync();
}

回顾一下,getFD方法会返回文件描述符,其sync方法会确保文件内容保存到设备上, saveMeta方法的代码为:

1
2
3
4
5
6
7
8
9
10
private void saveMeta() throws IOException{
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(metaFile)));
try{
saveIndex(out);
saveGaps(out);
}finally{
out.close();
}
}

索引信息和空白空间保存在一个文件中,saveIndex保存索引信息,代码为:

1
2
3
4
5
6
7
private void saveIndex(DataOutputStream out) throws IOException{
out.writeInt(indexMap.size());
for(Map.Entry<String, Long> entry : indexMap.entrySet()){
out.writeUTF(entry.getKey());
out.writeLong(entry.getValue());
}
}

先保存键值对个数,然后针对每条索引信息,保存键及值在.data文件中的位置。

saveGaps方法保存空白空间信息,代码为:

1
2
3
4
5
6
private void saveGaps(DataOutputStream out) throws IOException{
out.writeInt(gaps.size());
for(Long pos : gaps){
out.writeLong(pos);
}
}

也是先保存长度,然后保存每条空白空间信息。

我们使用了之前介绍的流来保存,这些代码比较烦琐,如果使用后续介绍的序列化,代码会更为简洁。

在构造方法中,我们提到了loadMeta方法,它是saveMeta的逆操作,代码为:

1
2
3
4
5
6
7
8
9
10
private void loadMeta() throws IOException{
DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream(metaFile)));
try{
loadIndex(in);
loadGaps(in);
}finally{
in.close();
}
}

loadIndex加载索引,代码为:

1
2
3
4
5
6
7
8
9
private void loadIndex(DataInputStream in) throws IOException{
int size = in.readInt();
indexMap = new HashMap<String, Long>((int) (size / 0.75f) + 1, 0.75f);
for(int i=0; i<size; i++){
String key = in.readUTF();
long index = in.readLong();
indexMap.put(key, index);
}
}

loadGaps加载空白空间,代码为:

1
2
3
4
5
6
7
8
private void loadGaps(DataInputStream in) throws IOException{
int size = in.readInt();
gaps = new ArrayDeque<>(size);
for(int i=0; i<size; i++){
long index = in.readLong();
gaps.add(index);
}
}

数据库关闭的代码为:

1
2
3
4
public void close() throws IOException{
flush();
db.close();
}

就是同步数据,并关闭数据文件。

14.2.4 小结

本节介绍了RandomAccessFile的用法,它可以随机读写,更为接近操作系统的API,在实现一些系统程序时,它比流要更为方便高效。利用RandomAccessFile,我们实现了一个非常简单的键值对数据库,我们演示了这个数据库的用法、接口、设计和实现代码。在这个例子中,我们同时展示了之前介绍的容器和流的一些用法。

这个数据库虽然简单粗糙,但也具备了一些优良特点,比如占用的内存空间比较小,可以存储大量键值对,可以根据键高效访问值等。