分享一下最近學會的序列化壓縮技巧。

情境如下,查詢資料庫後取得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指定給寫入器是比較通用的閃雷法。

Post a comment