計算機程式的思維邏輯 (60) - 隨機讀寫檔案及其應用 - 實現一個簡單的KV資料庫

swiftma發表於2016-12-29

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (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下載:github.com/swiftma/pro…

訪問檔案還有一種方式,那就是記憶體對映檔案,它有什麼特點?有什麼用途?讓我們下節繼續探索。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (60) - 隨機讀寫檔案及其應用 - 實現一個簡單的KV資料庫

相關文章