最近的 Coding4Fun 專案是練習用 Razor Pages 寫英文單字測驗網站,其中有個情境是每次測驗時可選擇涵蓋難度等級、是否只顯示打星號的單字、是否略過已記住的單字,最後產生一組單字清單當成題庫。 為了方便能用相同字庫重新測驗一次比較成績,系統要保留完整單字清單及每題選擇題選項確保測驗基準相同。

    /// <summary>
    /// 測驗設定
    /// </summary>
    public class TestSetting
    {
        /// <summary>
        /// 涵蓋難度等級
        /// </summary>
        public int[] Levels { get; set; } = new int[] { 1, 2, 3, 4, 5, 6 };
        /// <summary>
        /// 是否只包含標記星號的單字
        /// </summary>
        public bool StarOnly { get; set; }
        /// <summary>
        /// 是否排除註記跳過的單字
        /// </summary>
        public bool Skip { get; set; }
        /// <summary>
        /// 字彙清單
        /// </summary>
        public short[] WordIds { get; set; }
        /// <summary>
        /// 每題單字選擇題的選項
        /// </summary>
        public Dictionary<short, short[]> Options { get; set; }
    }

我計劃用 JSON 方式保存測試設定,若測驗範圍包含全部個單字(8,799個),JSON 大小將近 250KB,肥大了點,便興起讓它瘦身的念頭。

最先想到的方法是將 JSON ZIP 壓縮,於是我寫了以下的共用函式:

using Newtonsoft.Json;
using System.IO;
using System.IO.Compression;
using System.Text;

namespace EngVoc7000.Model
{
    public class ZipJsonHelper
    {
        /// <summary>
        /// JSON序列化後並壓縮
        /// </summary>
        /// <param name="data">要JSON化的資料物件</param>
        /// <returns>經ZIP壓縮的JSON資料</returns>
        public static byte[] SerializeZipJson(object data)
        {
            var json = JsonConvert.SerializeObject(data);
            using (var ms = new MemoryStream())
            {
                using (var zip = new DeflateStream(ms, CompressionMode.Compress))
                {
                    var buff = Encoding.UTF8.GetBytes(json);
                    zip.Write(buff, 0, buff.Length);
                }
                return ms.ToArray();
            }
        }

        /// <summary>    /// <summary>
    /// 測驗設定
    /// </summary>
    public class TestSetting
    {
        /// <summary>
        /// 涵蓋難度等級
        /// </summary>
        public int[] Levels { get; set; } = new int[] { 1, 2, 3, 4, 5, 6 };
        /// <summary>
        /// 是否只包含標記星號的單字
        /// </summary>
        public bool StarOnly { get; set; }
        /// <summary>
        /// 是否排除註記跳過的單字
        /// </summary>
        public bool Skip { get; set; }
        /// <summary>
        /// 字彙清單
        /// </summary>
        public int[] WordIds { get; set; }
        
        
    }
        /// 反序列化經壓縮的JSON
        /// </summary>
        /// <typeparam name="T">反序列化還原型別</typeparam>
        /// <param name="data">經ZIP壓縮的JSON資料</param>
        /// <returns>還原後的物件</returns>
        public static T DeserializeZipJson<T>(byte[] data)
        {
            using (var ms = new MemoryStream(data))
            {
                using (var zip = new DeflateStream(ms, CompressionMode.Decompress))
                {
                    using (var msJson = new MemoryStream())
                    {
                        zip.CopyTo(msJson);
                        var json = Encoding.UTF8.GetString(msJson.ToArray());
                        return JsonConvert.DeserializeObject<T>(json);
                    }
                }
            }
        }
    }
}

弄個 8800 字的模擬資料測試一下:

var count = 8800;
var rnd = new Random(9527);
var setting = new TestSetting()
{
    WordIds = Enumerable.Range(0, count).Select(o => (short)o).ToArray(),
    Options = Enumerable.Range(0, count).ToDictionary(
        o => (short)o, 
        o => Enumerable.Range(0, 3).Select(p => (short)rnd.Next(count)).ToArray())
};
var json = JsonConvert.SerializeObject(setting);
var zipJson = ZipJsonHelper.SerializeZipJson(setting);
Debug.WriteLine($"JSON size = {Encoding.UTF8.GetByteCount(json)}");
Debug.WriteLine($"Zip JSON size = {zipJson.Length}");
var res = ZipJsonHelper.DeserializeZipJson<TestSetting>(zipJson);
Debug.WriteLine($"Deserialization Check = {JsonConvert.SerializeObject(res)==json}");

效果不錯,資料從 250KB 縮到 105KB,使用上也很簡便。

JSON size = 249697
Zip JSON size = 105120
Deserialization Check = True

還能不能再縮小呢? 我決定繼續挑戰,連資料結構都納入優化範圍。short[] WordIds 與 Dictionary<short, short[]> 的 Keys 是重複的,宣告一個 Question 物件包含 short WordId 與 short[] Options,用單一 Question[] 陣列取代原本兩種資料。 至於資料儲存格式,我想將 Question[] 陣列用自訂格式轉成 byte[] 保存,除了每個數字可再減少 1-2 個 Byte(1-4 位數字 UTF8 原本 1-4 Bytes,轉 short 只要 2 Bytes),物件陣列純二進位格式,再省去大量「,」以及 [ ] 等符號。 程式要費點手腳,但難不倒我。

我將 Question[] Questions 標為 [JsonIgnored],其資料實際保存於 byte[] QuestionBin。 Question[] Questions 改為 get 時動態解析 QuestionBin 傳回 Question[] (有加上 Cache 概念避免重複解析), set 時則動態將 Question[] 依自訂邏輯轉成 byte[],並記錄 QuestionCount 供後續解析。 這種實際資料儲存格式與應用資料型別獨立處理的技巧蠻好用的,尤其是應用在將物件儲存資料庫時,例如:C# 端看到的是物件陣列,寫入資料表時自動轉成 JSON。 延伸閱讀:C# 小技巧:讓複雜型別屬性與資料庫欄位無縫接軌 二進位格式版程式如下:

public class Question
{
    [JsonProperty("q")]
    public short WordId { get; set; }
    [JsonProperty("o")]
    public short[] Options { get; set; }
}

/// <summary>
/// 測驗設定
/// </summary>
public class TestSetting
{
    /// <summary>
    /// 涵蓋難度等級
    /// </summary>
    public int[] Levels { get; set; } = new int[] { 1, 2, 3, 4, 5, 6 };
    /// <summary>
    /// 是否只包含標記星號的單字
    /// </summary>
    public bool StarOnly { get; set; }
    /// <summary>
    /// 是否排除註記跳過的單字
    /// </summary>
    public bool Skip { get; set; }
    /// <summary>
    /// 題目筆數
    /// </summary>
    public int QuestionCount { get; set; }

    private Question[] _questions = null;

    int optionCount = 0;
    private IEnumerable<Question> ParseQuestionBin(byte[] data)
    {
        using (var ms = new MemoryStream(data))
        {
            using (var r = new BinaryReader(ms))
            {
                var wordCount = data.Length / 2 / (optionCount + 1);
                for (var i = 0; i < wordCount; i++)
                {
                    var wordId = r.ReadInt16();
                    var list = new List<short>();
                    for (var j = 0; j < optionCount; j++)
                        list.Add(r.ReadInt16());
                    yield return new Question() { WordId = wordId, Options = list.ToArray() };
                }
            }
        }
    }

    /// <summary>
    /// 每題單字選擇題的選項
    /// </summary>
    [JsonIgnore]
    public Question[] Questions
    {
        get
        {
            if (QuestionBin != null)
            {
                if (_questions == null)
                {
                    //前題:每個單字選項個數固定
                    if (QuestionBin.Length / 2 % QuestionCount != 0)
                        throw new ApplicationException("QuestionBin 長度與 QuestionCount 不吻合");
                    optionCount = (QuestionBin.Length / QuestionCount / 2) - 1;
                    _questions = ParseQuestionBin(QuestionBin).ToArray();
                }
                return _questions;
            }
            return new Question[] { };
        }
        set
        {
            if (value != null && value.Any())
            {
                using (var ms = new MemoryStream())
                {
                    using (var w = new BinaryWriter(ms))
                    {
                        var optCountChk = -1;
                        value.ToList().ForEach(o =>
                        {
                            w.Write(o.WordId);
                            if (optCountChk == -1)
                                optCountChk = o.Options.Length;
                            else if (optCountChk != o.Options.Length)
                                throw new ArgumentException("選項長度不一致@" + o.WordId);
                            o.Options.ToList().ForEach(p => w.Write(p));
                        });
                    }
                    QuestionBin = ms.ToArray();
                    QuestionCount = value.Length;
                }
            }
            else
            {
                QuestionBin = null;
            }
            _questions = null;
        }
    }
    /// <summary>
    /// 問題資料(自訂二進位格式儲存)
    /// </summary>
    public byte[] QuestionBin { get; set; }
}

產生模模擬資料的程式需要小改:

var setting = new TestSetting()
{
    Questions = Enumerable.Range(0, count).Select(
        o => new Question()
        {
            WordId = (short)o,
            Options = Enumerable.Range(0, 3).Select(p => (short)rnd.Next(count)).ToArray()
        }).ToArray()
};

試跑改良版,原始 JSON 大小由 250KB 縮小至 93KB,壓縮版由 105 KB 減少到 70KB。

JSON size = 93960
Zip JSON size = 69768
Deserialization Check = True

玩夠了,繼續上工。 😛

Sample code to reduce JSON size with DeflateStream compression. Further, using binary format reduce the size to half.


Comments

Be the first to post a comment

Post a comment