設計應用的二進位存儲格式有什麼要點?

比方說

  • 設計一種資料庫的存儲文件格式,有什麼理論?

  • 設計一種圖片的格式,該如何入手?

  • 文件格式對於性能可能會有何種類型的影響?


1. 數據文件的生命期比可執行文件長,要注意版本向下兼容,新軟體可以打開舊版的文件,而不需要保留舊版的代碼。

2. 要利於多種語言讀寫,方便編寫各種工具,因此不要用對象序列化當文件格式。你怎麼用Python操作Java或Boost序列化的文件?

3. 定長整數通常會成為將來擴展的瓶頸,可以考慮varint。

綜上,如果不知道如何設計,就先設計 sorted key/value pairs 格式,再用 protobuf 當 value 。


最好不要自己設計,多用現成的格式


數據端的頭上或尾巴上多加個一個長度描述,總不是什麼壞事。


別的我不知道,我只知道千萬不要像某腦殘字體格式,偏移量的起始地址是從其所在位置開始算的。大致長這樣

offset of x[0] x[1] x[2]
3 3 3 x[0] x[1] x[2]


1)前面有答友提到兼容性和可擴展性問題;

2)還應該考慮壓縮,容錯,以及對IO的影響;


分享一下我設計實現的過程

總結一下就是

  • 最開始有 文件頭 包括 魔數 版本號 md5校驗值
  • 文件體分段 每段有 類型 偏移值(數據部分的位元組數) 數據 三部份組成

這篇文章就是分享一下我為自己的矢量繪圖程序實現二進位保存的過程

總的來說我需要使用這個文件來使程序中的一些類回復到保存時的狀態

目前有4個類

public class Camera {
private float mwidth;
private float mheight;
private float moffsetX = 0;
private float moffsetY = 0;
.
.
.
}
public class PaintManager {
private static PaintStyle mPaintStyle;
.
.
.
}
public class PaintStyle{
private int color = Color.BLACK;
private int alpha = 255;
private float blur = 0;
private float size = 10;
.
.
.
}
public class PathCollection {
LinkedList& theWrold = new LinkedList&<&>();
private int size = 0;
.
.
.
}

所謂的使類恢復到某個狀態 其實就是保存並設置類中的屬性罷了,畢竟類是屬性和方法的集合.

很明顯只有這個類自身才知道如何保存和恢復他自身. 所以每個需要恢復的類都最起碼有 init(初始化) preserve(保存) 這兩個方法 為了獲得類型 還必須有一個 byte getByte() 也就是說他們都必須 實現一個reincarnation(重生)介面

public interface reincarnation {
void init(InputStream in);
int preserve(OutputStream out);
byte getByte();
}

現在假設我們所有的類都已經實現了這個介面 那麼該如何使用哪?

要保存文件和打開文件

public class SaveTool {
public static void save(File f) {

}
public static void recover(File data){

}
}

在保存文件時 我們應該先計算文件體 這樣才能獲得到其的md5

private static void save(File f) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Camera.getInstance().preserve(bos);
PaintManager.getInstance().preserve(bos);
PathCollection.getInstance().preserve(bos);

byte[] data = bos.toByteArray();
byte[] md5 = calMD5(data);

ByteArrayOutputStream out = new ByteArrayOutputStream();
String magic = "vdg";//魔數
int majorVersion = 0;//主要版本號
int minorVersion = 1;//次要版本號

//文件頭 魔數(3) 主版本號(1) 次版本號(1) md5(16)
byte[] magbs = ConvertData.getANSCII(magic);
byte mav = ConvertData.getByte(majorVersion);
byte minv = ConvertData.getByte(minorVersion);

//head
out.write(magbs);
out.write(mav);
out.write(minv);
out.write(md5);

out.write(data);
writeBytesToFile(out.toByteArray(), f);
}

我們使用 ByteArrayOutputStream(Java自帶)來寫進byte值 最後通過 writeBytesToFile(byte[]data,File f) 來將byte數組寫入文件中

public static void recover(File vdata){

byte[] data = readBytesToFile(vdata);//獲取到全部的byte

ByteArrayInputStream in = new ByteArrayInputStream(data);
checkHead(data);

in.read(new byte[21]);//流出頭

Camera.getInstance().init(in);

PaintManager.getInstance().init(in);

PathCollection.getInstance().init(in);
}

恢復時同樣簡單 首先檢查頭部信息(魔數 版本 MD5)

這樣我們的二進位文件就保存成功了

笑.

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

好了現在讓我們回到Camera 中來 Camera有4個float 類型的屬性 所以他的preserve 應該是

@Override
public int preserve(OutputStream out){

try {
ByteArrayOutputStream data = new ByteArrayOutputStream();

byte[] v = ConvertData.tobytes(new float[]{mheight, mwidth, moffsetX, moffsetY});
data.write(getByte());
data.write(ConvertData.tobytes(v.length));
data.write(v);

out.write(data.toByteArray());

data.close();

return data.size();
} catch (IOException e) {
throw new RuntimeException("保存出錯");
}

}

相應的init 應該是

@Override
public void init(InputStream in) {
try {
byte t = (byte) in.read();
if (t != getByte()) {
throw new RuntimeException("檢測到的類型" + t + "與此處(Camera)衝突 ");
}

float[] v = ConvertData.tofloats(in,getInt(in));

float height = v[0];
float width = v[1];
moffsetX = v[2];
moffsetY = v[3];
} catch (IOException e) {
throw new RuntimeException("IO 錯誤");
}
}

getByte()則可以很簡當的在每個類中定義一個常量 返回即可

public static final byte type = 3;

@Override
public byte getByte() {
return type;
}

屬性只有基本數據類型的 可以這樣寫 如果屬性有類呢? 下面我們看PaintManager 他就持有一個PaintStyle (PaintStyle只有基本數據類型 同Camera)

同上 PaintManager的preserver

@Override
public int preserve(OutputStream out) {
try {
ByteArrayOutputStream data = new ByteArrayOutputStream();

ByteArrayOutputStream sub = new ByteArrayOutputStream();

int len = mPaintStyle.preserve(sub);

data.write(getByte());
data.write(ConvertData.tobytes(len));
data.write(sub.toByteArray());

out.write(data.toByteArray());

return data.size();

} catch (IOException e) {
throw new RuntimeException("保存出錯");
}

}

因為我們要獲得的偏移值是數據部分的位元組數 而PaintManager持有PaintStyle 所以 我們要新定義一個ByteArrayOutputStream sub 來放置這個類持有的對象 之後 再將sub加入到data中 最後放到總的out中

init 則很簡單 只要剝去頭部信息再轉交給PaintStyle就行了

public void init(InputStream in) {
if (in.read()!=getByte()) {
throw new RecoverFailException("類型錯誤(PaintManager)");
}
int len = ConvertData.getInt(in);//流出長度
mPaintStyle.init(in);
}

如此就能保存和恢復程序

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

這麼做是可行的 簡單純樸粗暴 但是很醜陋 很多重複的代碼

ByteArrayOutputStream data = new ByteArrayOutputStream();
ByteArrayOutputStream sub = new ByteArrayOutputStream();

int len = mPaintStyle.preserve(sub);

data.write(getByte());
data.write(ConvertData.tobytes(len));
data.write(sub.toByteArray());
out.write(data.toByteArray());
return data.size();

這段代碼只有 mPaintStyle.preserve(sub); 是有意義的 其他的每個類都要寫 都是重複

這個函數的理想狀態是 我們不用管 type len add 什麼的 每個需要保存的都會設置這些

所以 應該把它們提升到父類中

另外 write(XX)和ConvertData.XX 我也寫煩了 就保存來說 是不需要返回值的 所以我們可以寫成流暢界面的感覺

add(XX).add(XX).add(XX)的feel

我們先來解決流暢界面 很明顯可以封裝一下 ByteArrayOutputStream

public class ReOutputStream extends ByteArrayOutputStream {

public ReOutputStream add(byte b) {
super.write(b);
return this;
}

public ReOutputStream add(byte[] bs) {
try {
super.write(bs);
return this;
} catch (IOException e) {
throw new RuntimeException("寫入出錯");
}
}

public ReOutputStream add(int data) {
add(tobytes(data));
return this;
}

public ReOutputStream add(int[] data) {
add(tobytes(data));
return this;
}

public ReOutputStream add(float data) {
add(tobytes(data));
return this;
}

public ReOutputStream add(float[] data) {
add(tobytes(data));
return this;
}

public ReOutputStream add(ReOutputStream data) {
add(data.toByteArray());
return this;
}
}

ByteArrayInputStream 也是同樣

public class ReInputStream extends ByteArrayInputStream {
public ReInputStream(byte[] buf) {
super(buf);
}

public ReInputStream(byte[] buf, int offset, int length) {
super(buf, offset, length);
}

public int toInt() {
try {
byte[] data = new byte[4];
read(data);
return ConvertData.bytesToInt(data, 0);
} catch (IOException e) {
throw new RuntimeException("toInt出錯");
}
}

/**
* 讀取n位元組 轉換成int[]
*
* @param n byte 數
* @return
*/
public int[] toInts(int n) {
try {
byte[] data = new byte[n];
read(data);
return ConvertData.getIntArray(data);
} catch (IOException e) {
throw new RuntimeException("toInts出錯");
}
}

public int[] toNInts(int n) {
return toInts(n * 4);
}

public float toFloat(int n) {
try {
byte[] data = new byte[4];
read(data);
return ConvertData.bytesToFloat(data, 0);
} catch (IOException e) {
throw new RuntimeException("toFloat出錯");
}
}

public float[] toFloats(int n) {
try {
byte[] data = new byte[n];
read(data);
return ConvertData.tofloats(data);
} catch (IOException e) {
throw new RuntimeException("toInts出錯");
}
}

public float[] toNFloas(int n) {
return toFloats(n * 4);
}

public ReInputStream get(int[] d) {
try {
ConvertData.bytesToInts(this,d);
return this;
} catch (IOException e) {
throw new RuntimeException("出錯");
}
}
public ReInputStream get(float[] d) {
try {
ConvertData.bytesTofloats(this,d);
return this;
} catch (IOException e) {
throw new RuntimeException("出錯");
}
}
}

這樣我們在使用時就可以直接用

out.add(new float[]{mheight, mwidth, moffsetX, moffsetY, horizon.right, horizon.bottom, mratio});

最終我們來設計一個抽象父類ReLoad

在繼承他之後 只要在preserver中放入數據 在init中獲得並設置數據即可

@Override
public ReOutputStream preserve(ReOutputStream out) {
return out.add(new float[]{mheight, mwidth, moffsetX, moffsetY, horizon.right, horizon.bottom, mratio});
}

@Override
public void init(ReInputStream in, int len) {

float[]v=new float[4];
in.get(v);
height = v[0];
width = v[1];
moffsetX = v[2];
moffsetY = v[3];
}

public abstract class ReLoad {

private abstract byte getByte();

public ReOutputStream save(ReOutputStream out) {
try {
ReOutputStream data = new ReOutputStream();

preserve(data);

out.add(getByte())
.add(data.size())
.add(data);
data.close();
return out;
} catch (IOException e) {
throw new RuntimeException("保存錯誤");
}
}

public void recover(ReInputStream in) {
if (in.read() != getByte()) {
throw new RuntimeException("類型錯誤 " + getByte());
}
L.d(TAG, getByte());
int len[] = new int[1];
in.get(len);
byte[] data = new byte[len[0]];//不能污染了
try {
in.read(data);
} catch (IOException e) {
e.printStackTrace();
}
init(new ReInputStream(data), len[0]);
}

public abstract void init(ReInputStream in, int len);

public abstract ReOutputStream preserve(ReOutputStream out);
}

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

還能給給力一點嗎?

現在每個類都要實現getByte()來獲得代表此類的byte 然而自己自動加一個byte全局變數是一件很痛苦的事

我們可以直接在ReLoad中實現

public abstract class ReLoad {
private byte getByte(){
return TypeMap.get(this);
}
。。。。

}

public class TypeMap {
//TODO 應該是類名的hash的
private static HashMap& map=new HashMap&<&>();
public static byte get(Object obj) {
String name=obj.getClass().getName();
Object v= map.get(name);
if (v==null){
int type=map.size();
if (type&>255){
throw new RuntimeException("類過多 無法再使用byte");
}
map.put(name,(byte)type);
return (byte)type;
}else {
return (byte) v;
}
}
}

(T_T) 大致就是如此 只是做了一些微小的工作


我談一些微小的人生經驗:

一般上講,二進位格式的數據文件通常會存儲:

1. 元信息。比如:一些圖片格式中關於圖片的解析度、色彩空間等元信息。

2. 純數據信息。比如:BMP 圖片中的純像素數據。

考慮如下因素:

1. 元信息是固定的嗎?

2. 元信息放在文件頭嗎?

3. 現有的數據交換格式(Json/Bson、XML、YAML、Toml等)是否具有可取之處?

4. 可否使用一些序列化庫?

可以參考的例子:

1. 固定長度、固定格式的元信息 + 數據:BMP 點陣圖的存儲格式;

2. 類 XML 這樣的樹形、嵌套存儲格式:DICOM 數據文件(DICOM 數據文件可以大致看作是二進位形式的 XML);

3. ZIP 文件流(也就是說把 ZIP 格式當作一個容器,然後把元信息+數據塞到裡邊):Mozilla Firefox 的 MAFF Archive 格式的離線網頁存儲。

4. Bson:https://github.com/mongodb/libbson

----


給你可能犯的錯誤準備個後門:至少要加個版本號


一般而言,分為兩部分:

1)文件頭,欄位一般包括後面的文件體的長度,版本,校驗碼等等,一般而言文件頭是固定長度的。

2)文件體,真正存數據的部分,這部分要考慮的擴展和兼容性。我建議兩個方案,一個是所謂的TLV(Type-Length-Value)格式,即每段數據都有一個類型、長度標識,然後才是真正的數據部分,處理起來根據類型找到對應的處理方式,長度來獲取數據的內容。

第二個方案是使用類似protobuf這樣的可擴展性的格式,裡面的欄位使用optional即可。

另外有一點,很多人習慣用一個特殊字元來表示某一部分的結束,比如HTTP協議使用
,個人不是很建議這樣,因為提高了查找成本,還是使用長度來表示數據的長度來讀取數據更好些。


我覺得,把一種歷史悠久的文件格式各版本差異研究一下,比如bmp,就能學到很多經驗。

1、數據一定要帶版本號

2、向後兼容好實現,向前兼容是難點


asn.1不能滿足你的要求嗎至少可以參考,如果不是性能原因文本歡迎你。二進位對調試,移植,網路傳輸,版本兼容都是挑戰


看下常用開源實現


盡量用現成的。


推薦閱讀:

各個編程語言的發明者是否能夠隨便反編譯各自的已編譯的二進位文件?
編譯原理學了有什麼用?
從編譯原理上講,未來可能會出現既友好又高性能的高級語言嗎?
不學習編譯原理對於CS專業的學生有多大的損失?
C++ 中的左值、右值、左值引用、右值引用、引用分別是什麼,有哪些關係?

TAG:文件格式 | 計算機科學 | 編譯原理 |