物件序列化之舉手之勞省空間
| | | 5 | |
分享一下最近學會的序列化壓縮技巧。
情境如下,查詢資料庫後取得List<User>物件,打算透過序列化成檔案的方式保存,方便日後能快速還原回List<User>查詢比對,以達到離線使用的目標。
在.NET要玩序列化不過是小事一樁,只要針對類別建構出DataContractSerializer物件,再搭配FileStream,一個SerializeObject()指令就能將物件儲存成檔案,還原時也只要一個Read()指令就搞定,十分方便。
以下程式模擬了一個20萬筆資料的巨型集合物件 -- List<User>,以DataContractSerializer序列化為檔案,再反序列化回List<User>,並抽樣檢查還原的第1024筆資料,比對是否與序列化前相同,以確認資料正確無損。
using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.Serialization; using System.Text; namespace ZipSer { internal class Program
{ private static void Main(string[] args)
{ //隨機假造20萬筆User資料 List<User> bigList = GenSimData();
string fileName = "serialized.data";
int indexToTest = 1024; //用來比對測試的筆數
//序列化前取出第indexToTest筆資料的顯示內容 string beforeSer = bigList[indexToTest].Display, afterDeser = null; DataContractSerializer dcs =
new DataContractSerializer(bigList.GetType()); Stopwatch sw = new Stopwatch(); sw.Start();
//將List<User>序列化後寫入檔案 using (FileStream stm = new FileStream(fileName, FileMode.Create)) { dcs.WriteObject(stm, bigList);
}
sw.Stop();
Console.WriteLine("Serialization: {0:N0}ms", sw.ElapsedMilliseconds); sw.Reset();
sw.Start();
//由檔案反序列化還原回List<User> using (FileStream stm = new FileStream(fileName, FileMode.Open)) { //還原後一樣取出第indexToTest筆的User顯示內容 afterDeser =
(dcs.ReadObject(stm) as List<User>)[indexToTest].Display; }
sw.Stop();
Console.WriteLine("Deserialization: {0:N0}ms", sw.ElapsedMilliseconds); //比對還原後的資料是否相同 Console.WriteLine("Before: {0}", beforeSer); Console.WriteLine("After: {0}", afterDeser); Console.WriteLine("Pass Test: {0}", beforeSer.Equals(afterDeser)); Console.Read();
}
private static List<User> GenSimData()
{ List<User> lst = new List<User>(); Random rnd = new Random(); for (int i = 0; i < 200000; i++)
{ lst.Add(new User() { Id = Guid.NewGuid(),
RegDate =
DateTime.Today.AddDays(-rnd.Next(5000)),
Name = "User" + i, Score = rnd.Next(65535)
});
}
return lst; }
[Serializable]
private class User
{ public Guid Id { get; set; } public DateTime RegDate { get; set; } public string Name { get; set; }
public decimal Score { get; set; }
public string Display
{ get
{ return string.Format(
"{0} / {1:yyyy-MM-dd} / {2:N0}", Name, RegDate, Score);
}
}
}
}
}
執行結果如下:
Serialization: 2,349ms
Deserialization: 2,953ms
Before: User1024 / 2001-07-22 / 43,320
After: User1024 / 2001-07-22 / 43,320
Pass Test: True
序列化及反序列化的速度都蠻快的,花不到3秒,但有個問題:

序列化後的serialized.data檔案大小高達78MB!! 檔案痴肥是開發人員不夠專業的象徵,所以就快來研究如何改善吧!
要減少序列化後的體積,自訂更有效率的序列化邏輯是一種解決;而我看到序列化後的內容接近文字形式,則有另一個點子,何不在序列化存檔前進行壓縮縮小體積;在還原時,則讀檔後先解壓縮再做反序列化? 在種內容接近文字的情境,應能省下可觀空間,實做起來也會比自訂序列化邏輯簡單許多。
簡單嘗試後,發現GZipStream的設計概念很棒,我們只需為FileStream包上一層GZipStream,然後將WriteObject()及Read()的資料串流對象由FileStream改成GZipStream,只改個兩行程式,馬上就有壓縮/解壓縮功能!!
sw.Start();
//將List<User>序列化後寫入檔案 using (FileStream stm = new FileStream(fileName, FileMode.Create)) { //用GZipStream把FileStream包起來 using (GZipStream zip = new GZipStream(stm, CompressionMode.Compress)) { //序列化結果改寫入GZipStream dcs.WriteObject(zip, bigList);
}
}
sw.Stop();
Console.WriteLine("Serialization: {0:N0}ms", sw.ElapsedMilliseconds); sw.Reset();
sw.Start();
//由檔案反序列化還原回List<User> using (FileStream stm = new FileStream(fileName, FileMode.Open)) { //一樣用GZipStream把FileStream包起來 using (GZipStream zip = new GZipStream(stm, CompressionMode.Decompress)) { //還原的二進位資料來源改為GZipStream afterDeser =
(dcs.ReadObject(zip) as List<User>)[indexToTest].Display; }
}
sw.Stop();
Console.WriteLine("Deserialization: {0:N0}ms", sw.ElapsedMilliseconds); 測試結果,加入壓縮解壓程序後,執行時間略為增加(也只多出不到2秒),比對測試則驗證還原後資料無損。
Serialization: 4,072ms
Deserialization: 3,801ms
Before: User1024 / 2007-08-25 / 791
After: User1024 / 2007-08-25 / 791
Pass Test: True
接著來驗收壓縮成果,原本78.5MB的檔案,壓縮後只剩11.3MB,縮小為14%。

只用幾行程式,就讓序列化增加了壓縮功能,又到了該為.NET歡呼的時刻,讚啦!!!
Comments
# by Laneser
這個 GzipStream 我只有遇到一個地雷...就是壓縮之前大小不能超過 4GB ...
# by 蹂躪
那個限制在4.0就拿掉了
# by Ammon
為何不用 binary serialize?
# by Jeffrey
to Ammon, 有考慮過BinaryFormatter,但評估集合的資料物件有很大比例的屬性值是重複的,這部分只能透過壓縮來減少體積,使用Binary也幫不了太多忙。
# by Litfal
Deflate在C#裡面的大地雷不是4GB 是Block在太小時,壓縮率極差。 更甚者Block在某個大小時,壓縮後的體積反而誇張的增大。 (之前測試過,很奇怪的問題,用的block不大,好像是256左右,因為是測試用的程式碼,可能要找找看) 而用Stream傳遞給寫入器,最大的優點與缺點是封裝,隱藏寫入的細節,所以沒辦法知道寫入函式裡對於block如何處裡。 Serializer內容不清楚,雖然大多案例看起來沒問題,但總會害怕哪時忽然出事。 根據我實驗的結果,在建立GZipStream後,再用一層BufferedStream包起GZipStream,最後將BufferedStream指定給寫入器是比較通用的閃雷法。