專案裡有個小需求,Web API要以JSON格式傳回一個巨大物件(數十MB)。在.NET裡做JSON轉換,依我所知有三種選擇,JavaScriptSerializer、DataContractJsonSerializer及Json.NET。以前沒有想太多,覺得JavaScriptSerializer是.NET內建的,不像Json.NET還需要另外參照Library,又不像DataContractJsonSerializer得動用Stream、Encoding處理字串,應是最方便的做法,所以不少程式都用JavaScriptSerializer處理JSON轉換,長期下來除了日期格式的眉角,倒也沒什麼問題。

但這回在處理大型物件時,便突顯出JavaScriptSerializer的效能問題。用以下的範例來重現:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
using System.Web.Script.Serialization;
 
namespace ZipSer
{
    internal class Program
    {
        private static void Main(string[] args)
        {
            //隨機假造2萬筆User資料
            List<User> bigList = GenSimData();
            string fileName = "serialized.data";
            int indexToTest = 1024; //用來比對測試的筆數
            //序列化前取出第indexToTest筆資料的顯示內容
            string beforeSer = bigList[indexToTest].Display,
                afterDeser = null;
 
            JavaScriptSerializer jss = new JavaScriptSerializer();
            //要提高上限,否則物件較大時會產生例外
            jss.MaxJsonLength = int.MaxValue;
 
            Stopwatch sw = new Stopwatch();
            sw.Start();
            //將List<User> JSON化
            string json1 = jss.Serialize(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 =
                    (jss.Deserialize<List<User>>(json1))[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 < 20000; 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);
                }
            }
        }
    }
}

程式是用前一篇序列化文章的範例修改的,一樣隨機產生一個2萬筆資料的List<User>,但改用JavaScriptSerializer執行JSON序列化及還原。

途中會先遇到一顆地雷,預設JavaScriptSerializer能處理的資料規模有上限,當資料物件大到一定程度(JSON字串超過4MB),就會發生以下錯誤:

Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property.

因此,要調整MaxJsonLength屬性,很豪氣地一口氣設到int.MaxValue好了!

Serialization: 717ms
Deserialization: 65,844ms
Before: User1024 / 1999-03-28 / 3,749
After: User1024 / 1999-03-27 / 3,749
Pass Test: False

第二顆地雷出現了,測試的結果是False!! 這是以前提過的老問題。(DateTime經JavaScriptSerializer.Serialize()再JavaScriptSerializer.Deserialize()時會因時區標準不同,對台灣而言而產生8小時的時差,故1999-03-28 00:00:00會變成1999-03-27 16:00:00)

第三顆雷,瞎毁? 反序列化要65秒? 而且這還不是最誇張的,若試著把List<User>的陣列大小提高到30萬筆,jss.Deserialize()執行起來會沒完沒了,有種會一直到跑到天荒地老的fu... (至少已經遠超出我耐性的極限,沒等到結果我就卡歌了... 你知道的,身為一個中年程序員,可不想拿所剩不多的歲月跟它瞎耗) 想想,或許MaxJsonLength預設2097152是有原因的。

接著來試試DataContractJsonSerializer:

            DataContractJsonSerializer dcjs =
                new DataContractJsonSerializer(bigList.GetType());
 
            Stopwatch sw = new Stopwatch();
            sw.Start();
            //將List<User> JSON化
            MemoryStream ms = new MemoryStream();
            dcjs.WriteObject(ms, bigList);
            ms.Flush();
            string json1 = Encoding.UTF8.GetString(ms.ToArray());
            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顯示內容
                MemoryStream ms2 =
                    new MemoryStream(Encoding.UTF8.GetBytes(json1));
                afterDeser =
                    ((List<User>)dcjs.ReadObject(ms2))[indexToTest].Display;
            }
            sw.Stop();
            Console.WriteLine("Deserialization: {0:N0}ms", sw.ElapsedMilliseconds);

結果合理多了,序列化及反序化都約在0.5秒完成! 也沒有發生日期轉換誤差。

Serialization: 459ms
Deserialization: 568ms
Before: User1024 / 2010-09-13 / 38,262
After: User1024 / 2010-09-13 / 38,262
Pass Test: True

壓軸上場,請廣受好評的Json.NET出來露一手:

            Stopwatch sw = new Stopwatch();
            sw.Start();
            //將List<User> JSON化
            string json1 = JsonConvert.SerializeObject(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 =
                    (JsonConvert.DeserializeObject<List<User>>(json1))
                    [indexToTest].Display;
            }
            sw.Stop();
            Console.WriteLine("Deserialization: {0:N0}ms", sw.ElapsedMilliseconds);

測試成績出爐,與DataContractJsonSerializer相比,序列化速度慢一點點,但反序列化則快了不少:

Serialization: 536ms
Deserialization: 415ms
Before: User1024 / 2007-07-11 / 5,229
After: User1024 / 2007-07-11 / 5,229
Pass Test: True

綜合評量後,做個簡單結論:

  1. JavaScriptSerializer處理大型物件的效能令人髮指... (或許也是MaxJsonLength預設值不大的原因) 而日期時間還原後需要額外校正,看起來只適用於小型物件、沒有日期型別、不想加掛Library的場合。
  2. DataContractJsonSerializer屬BCL內建,雖然使用時必須動用MemoryStream,但也等於提供加入壓縮、加密或其他處理的方便管道,在某些情場下很有用。
  3. Json.NET的轉換語法最簡便(直接用static方法搞定,不需要建構物件),處理效能出色,日期時間格式預設也符合常見的ISO 8601標準(即2012-12-21T00:00:00Z),跟一些Client Library較無整合問題,還支援動態物件及其他附加特色,而以往被我嫌棄的需額外下載及加入參考的缺點,現在靠NuGet已能輕鬆解決,對我的需求而言,榮登最佳解決方案。

PS: 如想進一步了解,Json.NET網站有完整的功能比較表,也有一分跟JavaScriptSerializer、DataContractJsonSerializer的效能評測(不過,該案例應針對Json.NET的強項調整過,Json.NET的表現好得未免太嚇人 XD),還有說明文件(瀏覽文件後才發現Json.NET的功能跟擴充性真是踏馬的多),有興趣的朋友可以參考。


Comments

# by betaparticle

不好意思,我覺得程式看來跟你的文章有點不一樣 你註解寫 deserialize 是由檔案反序列 但是程式裡反序列用的 json1 字串,在檔案開起後沒有重讀,看來是之前序列化後的字串直接拿來用的。

# by Jeffrey

to detaparticle, 謝謝您的指正,這段程式借用前篇序列化為檔案文章的範例加以修改,結果該註解處沒有改到,確實有所疏漏,已更正。

# by Germos

不知道我有沒有看錯資訊, 目前 .NET Framework 4.5 正式版有考慮把 JSON.NET 納入嗎?

# by Jeffrey

to Germos, 印象中沒看到已經決定納入的消息。不過,Json.NET深得人心是事實,ASP.NET Web API也做出了類似抉擇: Removed System.Json.dll: Because of the overlap with functionality already in Json.NET the System.Json.dll assembly has been removed. (來源: http://weblogs.thinktecture.com/cweyer/2012/06/aspnet-web-api-changes-from-beta-to-rc.html) 我也樂見Json.NET被納入BCL,現在每次做專案都要NuGet加一下,雖然已經夠方便了,但如果能直接內建當然是求之不得。

# by Germos

恩, 我是看到了這篇文章提到: http://www.infoq.com/cn/news/2012/06/aspnet-mvc-4-rc

# by Jeffrey

to Germos, 了解,我想該文指的是同一件事: ASP.NET Web API改採Json.NET執行JSON轉換,應不算.NET Framework層次的政策(雖然我很樂見它發生 :P)。謝謝你的資訊。

# by Oliver

黑大, 關於MaxJsonLength值, 我想詢問一下, 在系統不斷使用的情況下, 所查詢出來的資料量會愈來愈大, 那這值不是需要一直去調整, 是否有作法能避免, 還是純料是我多慮了...^^"

# by Jeffrey

to Oliver, 如果你能預估最大可能傳輸的資料量,一口氣將MaxJsonLength設到上限就好了。依我的看法,如果你的資料會不斷成長,恐怕必須考量以分頁概念拆成多次傳輸較好。即便MaxJsonLength可以設成int.MaxValue,但序列化及反序列化所以需要的時間會長到難以想像,且龐大的資料經由Web傳輸困難度很高且易出錯,分批傳送將是較穩定可靠的做法,甚至要考慮改採二進位形式及壓縮等其他方式來解決鉅量資料問題。

# by Oliver

了解..謝謝黑大...

# by Carina

想起問黑大最近有用.NET轉Json,之前有google到教學用Json.NET,但將dll加入參考後卻一直出現"找不到參考元件"的錯誤訊息,嘗試換個dll但還是一樣,所以就改用內建JavaScriptSerializer了,但擔心資料量大時會出現黑大提的這個問題。 不知道黑大知不知道我再加入Json.net的dll時出了什麼問題?

# by Jeffrey

to Carina,我都是在Visual Studio中使用NuGet安裝Json.NET,經驗裡沒發生過找不到參照的狀況。要不要提供你加入參考的做法,讓大家看看哪個步驟有問題?

# by Victor Tseng

有趣的是 .net core 3.0 卻嫌 json.net 太胖,決定自己刻一個。是說網路上求快的似乎都推 Jil....

Post a comment