计算机程序的思维逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库
本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http://item.jd.com/12299018.html
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方法的代码也可以更为简洁。
设计
我们采用如下简单的设计:
- 将键值对分为两部分,值保存在单独的.data文件中,值在.data文件中的位置和键称之为索引,索引保存在.meta文件中。
- 在.data文件中,每个值占用的空间固定,固定长度为1024,前4个字节表示实际长度,然后是实际内容,实际长度不够1020的,后面是补白字节0。
- 索引信息既保存在.meta文件中,也保存在内存中,在初始化时,全部读入内存,对索引的更新不立即更新文件,调用flush才更新。
- 删除键值对不修改.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编程及计算机技术的本质。用心原创,保留所有版权。
计算机程序的思维逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库的更多相关文章
- Java编程的逻辑 (60) - 随机读写文件及其应用 - 实现一个简单的KV数据库
本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...
- Java编程的逻辑 (61) - 内存映射文件及其应用 - 实现一个简单的消息队列
本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...
- 计算机程序的思维逻辑 (64) - 常见文件类型处理: 属性文件/CSV/EXCEL/HTML/压缩文件
对于处理文件,我们介绍了流的方式,57节介绍了字节流,58节介绍了字符流,同时,也介绍了比较底层的操作文件的方式,60节介绍了随机读写文件,61节介绍了内存映射文件,我们也介绍了对象的序列化/反序列化 ...
- Perl IO:随机读写文件
随机读写 如果一个文件句柄是指向一个实体文件的,那么就可以对它进行随机数据的访问(包括随机读.写),随机访问表示可以读取文件中的任何一部分数据或者向文件中的任何一个位置处写入数据.实现这种随机读写的功 ...
- 计算机程序的思维逻辑 (8) - char的真正含义
看似简单的char 通过前两节,我们应该对字符和文本的编码和乱码有了一个清晰的认识,但前两节都是与编程语言无关的,我们还是不知道怎么在程序中处理字符和文本. 本节讨论在Java中进行字符处理的基础 - ...
- 计算机程序的思维逻辑 (29) - 剖析String
上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比 ...
- 计算机程序的思维逻辑 (38) - 剖析ArrayList
从本节开始,我们探讨Java中的容器类,所谓容器,顾名思义就是容纳其他数据的,计算机课程中有一门课叫数据结构,可以粗略对应于Java中的容器类,我们不会介绍所有数据结构的内容,但会介绍Java中的主要 ...
- 计算机程序的思维逻辑 (40) - 剖析HashMap
前面两节介绍了ArrayList和LinkedList,它们的一个共同特点是,查找元素的效率都比较低,都需要逐个进行比较,本节介绍HashMap,它的查找效率则要高的多,HashMap是什么?怎么用? ...
- Java基础之读文件——使用通道随机读写文件(RandomReadWrite)
控制台程序,使用通道随机读写primes_backup.bin文件. import static java.nio.file.StandardOpenOption.*; import java.nio ...
随机推荐
- SQL Server 致程序员(容易忽略的错误)
标签:SQL SERVER/MSSQL/DBA/T-SQL好习惯/数据库/需要注意的地方/程序员/容易犯的错误/遇到的问题 概述 因为每天需要审核程序员发布的SQL语句,所以收集了一些程序员的一些常见 ...
- Socket聊天程序——初始设计
写在前面: 可能是临近期末了,各种课程设计接踵而来,最近在csdn上看到2个一样问答(问题A,问题B),那就是编写一个基于socket的聊天程序,正好最近刚用socket做了一些事,出于兴趣,自己抽了 ...
- DDD初学指南
去年就打算总结一下,结果新换的工作特别忙,就迟迟没有认真动手.主要内容是很多初学DDD甚至于学习很长时间的同学没有弄明白DDD是什么,适合什么情况.这世界上没有银弹,抛开了适合的场景孤立的去研究DDD ...
- [APUE]标准IO库(上)
一.流和FILE对象 系统IO都是针对文件描述符,当打开一个文件时,即返回一个文件描述符,然后用该文件描述符来进行下面的操作,而对于标准IO库,它们的操作则是围绕流(stream)进行的. 当打开一个 ...
- Android数据加密之SHA安全散列算法
前言: 对于SHA安全散列算法,以前没怎么使用过,仅仅是停留在听说过的阶段,今天在看图片缓存框架Glide源码时发现其缓存的Key采用的不是MD5加密算法,而是SHA-256加密算法,这才勾起了我的好 ...
- [转载]Cookie/Session的机制与安全
Cookie和Session是为了在无状态的HTTP协议之上维护会话状态,使得服务器可以知道当前是和哪个客户在打交道.本文来详细讨论Cookie和Session的实现机制,以及其中涉及的安全问题. 因 ...
- 自己写的数据交换工具——从Oracle到Elasticsearch
先说说需求的背景,由于业务数据都在Oracle数据库中,想要对它进行数据的分析会非常非常慢,用传统的数据仓库-->数据集市这种方式,集市层表会非常大,查询的时候如果再做一些group的操作,一个 ...
- C#使用GET、POST请求获取结果
C#使用GET.POST请求获取结果,这里以一个简单的用户登陆为例. 1. 使用GET请求获取结果 1.1 创建LoginHandler.aspx处理页面 protected void Page_Lo ...
- Nginx反向代理,负载均衡,redis session共享,keepalived高可用
相关知识自行搜索,直接上干货... 使用的资源: nginx主服务器一台,nginx备服务器一台,使用keepalived进行宕机切换. tomcat服务器两台,由nginx进行反向代理和负载均衡,此 ...
- 说说BPM数据表和日志表中几个状态字段的详细解释
有个客户说需要根据这些字段的值作为判断条件做一些定制化需求,所以需要知道这些字段的名词解释,以及里面存储的值具体代表什么意思 我只好为你们整理奉上这些了! Open Work Sheet 0 Sav ...