Java编程的逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库

时间:2024-08-26 18:35:26

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接http://item.jd.com/12299018.html

Java编程的逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库


57节介绍了字节流, 58节介绍了字符流,它们都是以流的方式读写文件,流的方式有几个限制:

  • 要么读,要么写,不能同时读和写
  • 不能随机读写,只能从头读到尾,且不能重复读,虽然通过缓冲可以实现部分重读,但是有限制

Java中还有一个类RandomAccessFile,它没有这两个限制,既可以读,也可以写,还可以随机读写,它是一个更接近于操作系统API的封装类。

本节,我们介绍就来介绍这个类,同时,我们介绍它的一个应用,实现一个简单的键值对数据库,怎么实现数据库呢?我们先来看RandomAccessFile的用法。

RandomAccessFile

构造方法

RandomAccessFile有如下构造方法:

public RandomAccessFile(String name, String mode) throws FileNotFoundException
public RandomAccessFile(File file, String mode) throws FileNotFoundException

参数name和file容易理解,表示文件路径和File对象,mode是什么意思呢?它表示打开模式,可以有四个取值:

  • "r": 只用于读
  • "rw": 用于读和写
  • "rws": 和"rw"一样,用于读和写,另外,它要求文件内容和元数据的任何更新都同步到设备上
  • "rwd": 和"rw"一样,用于读和写,另外,它要求文件内容的任何更新都同步到设备上,和"rws"的区别是,元数据的更新不要求同步

DataInput/DataOutput接口

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

//读一个字节,取最低八位,0到255
public int read() throws IOException
public int read(byte b[]) throws IOException
public int read(byte b[], int off, int len) throws IOException
public final double readDouble() throws IOException
public final int readInt() throws IOException
public final String readUTF() throws IOException
public void write(int b) throws IOException
public final void writeInt(int v) throws IOException
public void write(byte b[]) throws IOException
public void write(byte b[], int off, int len) throws IOException
public final void writeUTF(String str) throws IOException
public void close() throws IOException

RandomAccessFile还有另外两个read方法:

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可以获取该指针,也可以更改该指针,相关方法是:

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

RandomAccessFile是通过本地方法,最终调用操作系统的API来实现文件指针调整的。

InputStream有一个skip方法,可以跳过输入流中n个字节,默认情况下,它是通过实际读取n个字节实现的,RandomAccessFile有一个类似方法,不过它是通过更改文件指针实现的:

public int skipBytes(int n) throws IOException

RandomAccessFile可以直接获取文件长度,返回文件字节数,方法为:

public native long length() throws IOException;

它还可以直接修改文件长度,方法为:

public native void setLength(long newLength) throws IOException;

如果当前文件的长度小于newLength,则文件会扩展,扩展部分的内容未定义。如果当前文件的长度大于newLength,则文件会收缩,多出的部分会截取,如果当前文件指针比newLength大,则调用后会变为newLength。

需要注意的方法

RandomAccessFile中有如下方法:

public final void writeBytes(String s) throws IOException
public final String readLine() throws IOException

看上去,writeBytes可以直接写入字符串,而readLine可以按行读入字符串,实际上,这两个方法都是有问题的,它们都没有编码的概念,都假定一个字节就代表一个字符,这对于中文显然是不成立的,所以,应避免使用这两个方法。

BasicDB的设计

在日常的一般文件读写中,使用流就可以了,但在一些系统程序中,流是不适合的,RandomAccessFile因为更接近操作系统,更为方便和高效。

下面,我们来看怎么利用RandomAccessFile实现一个简单的键值数据库,我们称之为BasicDB。

功能

BasicDB提供的接口类似于Map接口,可以按键保存、查找、删除,但数据可以持久化保存到文件上。

此外,不像HashMap/TreeMap,它们将所有数据保存在内存,BasicDB只把元数据如索引信息保存在内存,值的数据保存在文件上。相比HashMap/TreeMap,BasicDB的内存消耗可以大大降低,存储的键值对个数大大提高,尤其当值数据比较大的时候。BasicDB通过索引,以及RandomAccessFile的随机读写功能保证效率。

接口

对外,BasicDB提供的构造方法是:

public BasicDB(String path, String name) throws IOException

path表示数据库文件所在的目录,该目录必须已存在。name表示数据库的名称,BasicDB会使用以name开头的两个文件,一个存储元数据,后缀是.meta,一个存储键值对中的值数据,后缀是.data。比如,如果name为student,则两个文件为student.meta和student.data,这两个文件不一定存在,如果不存在,则创建新的数据库,如果已存在,则加载已有的数据库。

BasicDB提供的公开方法有:

//保存键值对,键为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。

使用

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

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

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转换为了字节。

后续章节,我们会介绍序列化,如果有序列化知识,我们可以将byte数组替换为任意可序列化的对象。即使也是使用byte数组,使用序列化,toBytes方法的代码也可以更为简洁。

设计

我们采用如下简单的设计:

  1. 将键值对分为两部分,值保存在单独的.data文件中,值在.data文件中的位置和键称之为索引,索引保存在.meta文件中。
  2. 在.data文件中,每个值占用的空间固定,固定长度为1024,前4个字节表示实际长度,然后是实际内容,实际长度不够1020的,后面是补白字节0。
  3. 索引信息既保存在.meta文件中,也保存在内存中,在初始化时,全部读入内存,对索引的更新不立即更新文件,调用flush才更新。
  4. 删除键值对不修改.data文件,但会从索引中删除并记录空白空间,下次添加键值对的时候会重用空白空间,所有的空白空间也记录到.meta文件中。

我们暂不考虑由于并发访问、异常关闭等引起的一致性问题。

这个设计显然是比较粗糙的,主要用于演示一些基本概念,下面我们来看代码。

BasicDB的实现

内部组成

BasicDB有如下静态变量:

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";

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

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

表示文件的数据结构是:

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

构造方法

构造方法的代码为:

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方法的代码是:

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方法的代码是:

private long nextAvailablePos() throws IOException{
if(!gaps.isEmpty()){
return gaps.poll();
}else{
return db.length();
}
}

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

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

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方法的代码为:

public byte[] get(String key) throws IOException{
Long index = indexMap.get(key);
if(index!=null){
return getData(index);
}
return null;
}

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

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方法的代码为:

public void remove(String key){
Long index = indexMap.remove(key);
if(index!=null){
gaps.offer(index);
}
}

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

同步元数据flush

flush方法的代码为:

public void flush() throws IOException{
saveMeta();
db.getFD().sync();
}

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

private void saveMeta() throws IOException{
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(metaFile)));
try{
saveIndex(out);
saveGaps(out);
}finally{
out.close();
}
}

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

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保存空白空间信息,代码为:

private void saveGaps(DataOutputStream out) throws IOException{
out.writeInt(gaps.size());
for(Long pos : gaps){
out.writeLong(pos);
}
}

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

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

加载元数据

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

private void loadMeta() throws IOException{
DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream(metaFile)));
try{
loadIndex(in);
loadGaps(in);
}finally{
in.close();
}
}

loadIndex加载索引,代码为:

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加载空白空间,代码为:

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);
}
}

关闭

数据库关闭的代码为:

public void close() throws IOException{
flush();
db.close();
}

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

小结

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

这个数据库虽然简单粗糙,但也具备了一些优良特点,比如占用的内存空间比较小,可以存储大量键值对,可以根据键高效访问值等。完整代码,可以从github下载:https://github.com/swiftma/program-logic。

访问文件还有一种方式,那就是内存映射文件,它有什么特点?有什么用途?让我们下节继续探索。

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

未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深入浅出,老马和你一起探索Java编程及计算机技术的本质。用心原创,保留所有版权。

Java编程的逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库