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是什麼意思呢?它表示打開模式,可以有四個取值:
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方法的代碼也可以更為簡潔。
設計
我們采用如下簡單的設計:
我們暫不考慮由於並發訪問、異常關閉等引起的一致性問題。
這個設計顯然是比較粗糙的,主要用於演示一些基本概念,下面我們來看代碼。
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編程及計算機技術的本質。用心原創,保留所有版權。