2014/5/1

[入門] JSON.NET 入門

JSON.NETJames Newton-King 所開發的 JSON 程式庫, 他從 2005 年底著手開發, 於 2006 六月發表, 至今已歷經將近十年的時間。英雄出少年, James 是個住在紐西蘭的年輕人, 現年才 31 歲而已。他稍後把 JSON.NET 移到 CodePlex 成為 Open Source 專案, 跟隨者有四千多人。目前 JSON.NET 是 Visual Studio 中最受歡迎的 NuGet 套件之一, 迄今已有五百一十萬個下載數, 僅略遜於 jQuery 的五百八十萬, 卻勝過 Entity Framework 的四百九十萬

"JSON" 是 JavaScript Object Notation 這幾個字的縮寫, 是 1999 年 ECMA-262 3rd Edition 中所定義的子集合, 可以在JavaScript以 eval() 函式解析讀取。但是慢慢地 JSON 開始被 JavaScript 之外的其它語言所支援, 於是它逐漸成為普遍受到歡迎的資料交換語言。

JSON 淺談

JSON 原本就是指 JavaScript 的物件標註方式。以下是一段 JavaScript 物件標註方法:

var pc = {
  'CPU': 'Intel',
  'Version': 3.,
  'Drives': [
    'DVD read/writer',
    '500 gigabyte hard drive'
  ]
}

如上, 以大括號 "{ .. }" 包住的區塊代表一個物件 (object), 以中括號 "[ .. ]" 包住的區塊代表陣列。第一行的 'CPU' 是該屬性的 Key, 冒號之後的 'Intel' 則是它的 Value。

以上述標註方式定義了 JavaScript 物件之後, 我們就可以使用 JavaScript 把該物件的值取出來:

console.log(pc.CPU);
console.log(pc.Version);

以下是示範程式, 讀者可以自行參考修改:

從這裡可以看出來, 如果與 XML 比較起來, 雖然這兩者都是樹狀結構的資料表示法, JSON 的表示法更為精簡, 它並沒有 XML 語言中的 Attribute 這種點綴式的屬性 (像 <Computer CPU="Intel" )。換句話說, 它能夠以更簡單的方式表達相同的東西。

所以, 基本上, JSON 的元素脫不了 KeyValuePair、Array 和 Object 這三種結構。除了這三種, 沒有其它了 (其實還有指令碼, 在這裡無關宏旨, 暫且不提)。

ECMA-262 5.1 中定義 (見 5.1.5 "The JSON Grammar"): "The JSON grammar is used to translate a String describing a set of ECMAScript objects into actual objects." 意思是 JSON 語法存在的目的, 是為了將文字內容轉換為 JavaScript 的物件。

然而, 雖然「JavaScript 的物件標註」的縮寫就是 JSON, JSON 文件的語法卻和上面的程式中的寫法有些不同。例如, 如果我們執行 JSON.parse("{ 'CPU': 'Intel' }") 這個指令, 那麼, 很抱歉, JavaScript 解譯器會出現 SyntaxError: Unexpected token 這道錯誤訊息。為什麼?

因為根據 ECMA-262 5.1 的定義 (見 15.12.1 "The JSON Grammar"), JSON 文件中的字串必須以雙引號包住, 不能使用單引號, 也不能沒有引號。把上述指令改成 JSON.parse('{"CPU":"Intel" }') 就可以了。

此外, JSON.parse('{"Version":3. }') 這道指令竟然也是錯的! 因為根據 ECMAScript 的規範, JSON 文件中的 decimal 數值不能省略小數點後面的 0; 你至少必須填入一個數字。所以, 改做 JSON.parse('{"Version":3.0 }') 就行了。

還有, JSON 文件裡可以允許 U+2028 (分行字元) 和 U+2029 (分段字元) 這兩種罕見的字元 (見 15.12.1 "parse ( text [ , reviver ] )") 而不必逸出, 但是在 JavaScript 程式裡卻不允許這兩個字元在字串中出現。

以上幾點, 都是 JSON 文件格式和 JavaScript 程式內的規範的不同之處。所以我們可能偶爾聽見有人很喜歡特別強調 JavaScript 內的物件標註方式「不是 JSON」! 我個人很不贊同這種講法。因為 JSON 原本就是「JavaScript 物件標註」的縮寫, 上面那種說法等於是說「JSON 不是 JSON」。這樣不是讓人更困擾嗎?

所以, 我偏向於說「JSON 文件的格式和 JavaScript 的物件標註方式略有不同」。這樣才不會誤導了初學者。

JSON 的應用

如果你想提早看看 JSON 的實際應用, 那麼你不妨在你的瀏覽器中輸入以下網址:

http://api-beta.ly.g0v.tw/v0/collections/sittings

這是由 g0v 網站所提供的免費 API 之一, 內容是立法院會議的內容。你可以在你的瀏覽器 (IE 除外) 中看到它所傳回的 JSON 字串如下(節錄):

{"paging":{"count":3766,"l":30,"sk":0},"entries":[{"session":5,"extra":null,"sitting":7,"summary":"繼續審查行政院函,
為該院大陸委員會授權財團法人海峽交流基金會與大陸海峽兩岸關係協會簽署之「海峽兩岸服務貿易協議」一案,業已核定,請查照
案(含協議本文、附件一服務貿易特定承諾表、附件二關於服務提供者的具體規定)。","committee":["內政委員會","外交及國防委
員會","經濟委員會","財政委員會","教育及文化委員會","交通委員會","司法及法制委員","社會福利及衛生環境委員會"],
"proceeding_url":null,"dates":[{"calendar_id":59978,"chair":"陳其邁","date":"2014-03-27","time_start":"14:30:00"... 

這個網站傳回的看來是一些密密麻麻擠在一起的 JSON 字串, 這時你可以前往以下這個網站:

http://jsonviewer.stack.hu/

把上面的 JSON 字串貼到它的 Text 頁籤裡之後, 然後你就可以在 Viewer 頁籤下看到像 TreeView 一樣, 可以開合顯示的樹狀結構, 方便檢視。

JSON.NET 初體驗

在我們的程式中, 我們能夠拿 JSON 做些什麼? 作為一種資料交換格式, JSON 當然是用來交換資訊用的, 其角色和 XML 沒什麼不一樣。所以我們能夠做的事情, 不外乎以下幾種:

  1. 將資料或 .Net 物件轉出為 JSON 文件
  2. 把 JSON 文件轉換成資料或 .Net 物件

有很多方式可以做上述幾件事情。但是既然本文介紹的是 JSON.NET, 所以我就不提其它方式了。不過, 前面提過, JSON 之所以成為最受歡迎的套件之一, 當然是因為它簡單好用又穩定的關係。如果你有需要處理 JSON 文件, 那麼你一定不能不知道這個套件。

到 VS2013 為止, JSON.NET 都不是 Visual Studio 的內建套件之一。你必須在你的專案中自己安裝。從你的方案總管中, 在專案名稱上面按滑鼠右鍵, 選擇「管理 NuGet 套件...」, 然後選擇 Json.NET 這個項目 (可以使用搜尋方式找到), 再選取安裝即可。

安裝好 JSON.NET 之後, 我們來看看它能如何簡單地幫我們產生 JSON 文件。

請在你的 Windows Form 專案中加入下列程式:

// 程式一
private void GenJsonDemo()
{
    dynamic pc = new JObject();
    pc.Add("Belong2", "Johnny");
    pc.Add("DatePurchased", DateTime.Now);

    dynamic HardDisk1 = new JObject();
    HardDisk1.Brand = "Seagate";
    HardDisk1.Size = 500;
    dynamic HardDisk2 = new JObject();
    HardDisk2.Brand = "Intel";
    HardDisk2.Type = "SSD";
    HardDisk2.Size = 128;
            
    dynamic Hardware = new JObject();
    Hardware.HardDisks = new JArray() as dynamic;
    Hardware.HardDisks.Add(HardDisk1);
    Hardware.HardDisks.Add(HardDisk2);
    Hardware.CPU = "Intel Core i5-3470 @ 3.20GHz";
    Hardware.RAMSize = 8;
    pc.Hardware = Hardware;

    dynamic Software = new JObject();
    Software.OS = "Windows 7 Professional 64-bit Service Pack 1";
    pc.Software = Software;

    dynamic App1 = new JObject();
    App1.Name = "Office 2013";
    dynamic App2 = new JObject();
    App2.Name = "Visual Studio 2013";
    App2.Version = "12.0.30110.00 Update 1";

    pc.Software.Apps = new JArray() as dynamic;
    pc.Software.Apps.Add(App1);
    pc.Software.Apps.Add(App2);

    rtLinq2Json.Text = pc.ToString();
}

不要被這段長長的程式嚇到了。如果你再仔細看一下, 就會發現它其實只是重複的做幾件事情而已:

  1. dynamic 型別建立 JObject 物件
  2. 運用 dynamic 可以動態指定成員的方式建立及指定子物件(可對照程式的輸出結果, 如下圖所示)
  3. 建立 JArray 物件以加入陣列物件
  4. 最後, 對根物件下達 ToString() 指令即可輸出 JSON 字串。

以下是程式的輸出結果:

各位可以看到, HardDisk1 和 HardDisk2 兩個物件被輸出時都變成匿名的。在 JSON 文件中, 陣列中不能放進具名物件。這是與 XML 文件不同之處之一。

至於 DatePurchase 的輸出, 如果你希望使用 1388505600000 這種格式, 那麼你可以使用我在「在 JSON.NET 中自動對應 JavaScript 時間」這篇文章中提供的方法, 把

pc.Add("DatePurchased", DateTime.Now);

改成

pc.Add("DatePurchased", ConvertToJsTime(DateTime.Now));

這樣就可以了。

此外, 建議你把陣列物件的識別字取名為英文的複數型式, 例如 HardDisks, Apps 等等, 甚至像 Infoes (雖然 info 或者 information 這個字是沒有複數型式的集合名詞, 英文裡也沒有 infoes 和 informations 這些字; 但是畢竟我們不是老外, 不一定要遵守他們的規則 -- 何況這種做法也不影響溝通, 即使有老外會看你的程式碼, 他也看得懂那是什麼意思)。在這個步驟裡, 我們看不出這麼做有什麼意義, 但是到了下個步驟, 你就會明白。

讀入 JSON 資料

到這裡為止, 我們已經可以任意產生資料並輸出為 JSON 格式。但是我們如何把像前面看過的, 像是從 g0v 網站取得的資料讀進程式裡呢?

最傳統的做法, 就是一個一個對應那個 JSON 文件的格式, 再慢慢「刻」成 C# 或 VB 類別。但是我們不需要這麼做。從 VS2012 開始, 我們只要把 JSON 字串「倒進」Visual Studio, 它就會自動幫我們產生類別。

現在請在 Visual Studio 中新增一個類別檔案, 命名為 G0vSitting, 然後把前面提到的 JSON 文字 (http://api-beta.ly.g0v.tw/v0/collections/sittings) 拷貝起來, 再選「編輯」、「選擇性貼上」、「貼上 JSON 作為類別」, Visual Studio 就會幫我們產生對應類別。把最頂層類別名稱改為 G0vSitting 後的程式碼如下:

// 程式二
namespace JsonNetDemo
{
    public class G0vSitting
    {
        public Paging paging { get; set; }
        public Entry[] entries { get; set; }
    }

    public class Paging
    {
        public int count { get; set; }
        public int l { get; set; }
        public int sk { get; set; }
    }

    public class Entry
    {
        public int session { get; set; }
        public object extra { get; set; }
        public int sitting { get; set; }
        public string summary { get; set; }
        public string[] committee { get; set; }
        public object proceeding_url { get; set; }
        public Date[] dates { get; set; }
        public Motion[] motions { get; set; }
        public int ad { get; set; }
        public string id { get; set; }
        public string name { get; set; }
    }

    public class Date
    {
        public int calendar_id { get; set; }
        public string chair { get; set; }
        public string date { get; set; }
        public string time_start { get; set; }
        public string time_end { get; set; }
    }

    public class Motion
    {
        public string motion_class { get; set; }
        public int agenda_item { get; set; }
        public int? subitem { get; set; }
        public object item { get; set; }
        public string bill_id { get; set; }
        public string bill_ref { get; set; }
        public string proposed_by { get; set; }
        public string summary { get; set; }
        public Doc doc { get; set; }
        public string sitting_introduced { get; set; }
    }

    public class Doc
    {
        public string pdf { get; set; }
        public string doc { get; set; }
    }
}

這個 JSON 內容是立法院會議的摘要內容。如果你對這些欄位的意義有興趣, 你可以參考立法院全球資訊網提供的中英名詞對照表

在程式二中各位可以看到, Visual Studio 很聰明地為 entries 陣列建立了一個 Entry 類別, 並且以 public Entry[] entries { get; set; } 來定義這個字串。就像我在前面說過的, 假設你是 JSON 的提供者, 如果你把陣列名稱以複數形式命名, 在這個階段, VS 就會自動幫你建立一個對應的、以單數名稱命名的子類別。但是, 如果你不使用複數名稱的話, 例如假設上述陣列是命名為 entry, 會發生錯誤嗎? 不會, VS 仍然會為你產生類別, 但是會變成 public Entry[] entry { get; set; }。這麼一來, 雖然不會造成任何問題, 但是你就無法一眼就看出 entry 這個物件其實是個 Entry 物件所組成的陣列。把陣列和 Collection 物件以複數名稱命名是個蠻普遍的慣例, 你不一定要遵守這個慣例, 不過如果沒有什麼特別的原因, 我還是建議你遵從這種慣例比較好。

接著, 我們在 G0vSitting 類別裡面加入一個 GetSittings() 靜態方法, 使得程式二中的 G0vSitting 類別變成如下:

// 程式三
namespace JsonNetDemo
{
    public class G0vSitting
    {
        public Paging paging { get; set; }
        public Entry[] entries { get; set; }
    }

    public static G0vSitting GetSittings(ref string err)
    {
        string url = "http://api-beta.ly.g0v.tw/v0/collections/sittings";
        G0vSitting sittings = new G0vSitting();
        try
        {
            using (WebClient webClient = new WebClient())
            {
                webClient.Encoding = Encoding.UTF8;
                string json = webClient.DownloadString(url);
                sittings = JsonConvert.DeserializeObject<G0vSitting>(json);
            }
        }
        catch (Exception ex)
        {
            err = ex.Message;
        }
        return sittings;
    }

    // 下略

使用 GetSittings() 方法, 我們可以讀取那個 JSON 來源並且匯入程式中, 並且傳回一個 G0vSitting 類別實體。

接著, 我們就可以使用這個 GetSittings() 方法取出資訊並予以顯示:

// 程式四
private void G0vDemo()
{
    string err = string.Empty;
    G0vSitting sittings = G0vSitting.GetSittings(ref err);
    if (null != sittings && string.IsNullOrEmpty(err))
        foreach (Entry e in sittings.entries)
        {
            rtG0vDemo.Text += "----------------------------------------\r\n";
            rtG0vDemo.Text += string.Format("主旨: {0}\r\n\r\n摘要: {1}\r\n\r\n",
                e.name, e.summary);
            foreach (Date d in e.dates)
            {
                rtG0vDemo.Text += string.Format("{0} {1} ~ {2} 主席: {3}\r\n\r\n",
                    d.date, d.time_start, d.time_end, d.chair);
            }
        }
    else
        rtG0vDemo.Text = "發生錯誤: " + err;
}

輸出畫面如下:

在程式三中, 最關鍵的一道指令就是 JsonConvert.DeserializeObject<G0vSitting>(json);。使用這個 JsonConvert.DeserializeObject() 方法, 就可以把已經讀取的 JSON 字串匯入指定的類別裡 (在這裡是 G0vSitting) 並傳回一個 instance。但是請記得前題是這個類別必須能夠與那個 JSON 字串互相匹配。在這個範例中, 因為該類別原本就是從 JSON 字串產生出來的, 所以不會有什麼問題, 但是實際上我們必須留意以下兩個陷阱:

  1. 除非 JSON 字串是你自己做的, 否則我們不一定知道正確的資料結構。例如, 你今天取得的 JSON 字串中並沒有 Remark 欄位, 所以 VS 幫你產生的類別自然也不會有這個欄位。但是如果你明天再去取一次資料, 它卻突然出現了一個 Remark 欄位, 那麼由於你的類別中沒有這個欄位, 所以你當然不會取到這個欄位的資料, 也不會出現任何錯誤。你很可能永遠都不知道其實資料來源中曾經有過這個欄位存在。
  2. VS 幫你產生類別時, 無法正確判斷型別。例如你今天取得的 JSON 字串的某個欄位是 8, VS 會幫你產生的欄位自然會是個 int。但是這個值很可能明天去取的時候變成了 3.2, 那麼你的程式就會出錯。當然, 最保險的做法就是把所有數字欄位都改成 float, 我不能幫你下這個決定, 你必須自己判斷資料來源的穩定性, 然後再下決定。

因此, 除非你有辦法取得資料來源的正式 API 格式, 否則你等於在對它做反向工程。在這種狀況下, 這種資料的擷取行為就不是完全可靠的, 你必須自己明白這個風險。

輸出為 JSON 資料

我在前面已經介紹過如何把 JSON 文字以你自己的類別格式匯入, 那麼, 要如何把你的既有資料匯出呢?

若使用 JSON, 至少有兩種方式可以做到。第一種做法我已經在最上面「JSON.NET 初體驗」這一節裡介紹過了。第二種做法則是直接把 C# 類別輸出為 JSON 文字。

現在, 請在專案中新增一個類別, 命名為 Pc.cs, 將它修改如下:

// 程式五
public class PC
{
    public string Belong2 { get; set; }
    public long DatePurchased { get; set; }
    public Hardware Hardware { get; set; }
    public Software Software { get; set; }
}

public class Hardware
{
    public Harddiskinfo[] HardDiskInfo { get; set; }
    public string CPU { get; set; }
    public int RAMSize { get; set; }
}

public class Harddiskinfo
{
    public string Brand { get; set; }
    public int Size { get; set; }
    public string Type { get; set; }
}

public class Software
{
    public string OS { get; set; }
    public App[] Apps { get; set; }
}

public class App
{
    public string Name { get; set; }
    public string Version { get; set; }
}

眼尖的朋友一定已經發現, 這個類別根本就是程式一所呈現的類別結構。如果我們把程式一裡指定的值原封不動地搬過來使用, 那麼建立一個類別物件並轉出為 JSON 文件的程式如下:

private void GenJsonFromClassDemo()
// 程式六
{
    PC pc = new PC()
    {
        Belong2 = "Johnny",
        DatePurchased = DateTime.Now,
        Hardware = new Hardware()
        {
            HardDiskInfoes = new Harddiskinfo[] 
            { 
                new Harddiskinfo() { Brand="Seagate", Size=500},
                new Harddiskinfo() { Brand="Intel", Size=128, Type="SSD"} 
            },
            CPU = "Intel Core i5-3470 @ 3.20GHz",
            RAMSize = 8
        },
        Software = new Software()
        {
            OS = "Windows 7 Professional 64-bit Service Pack 1",
            Apps = new App[] 
            { 
                new App() {Name="Office 2013"},
                new App() {Name="Visual Studio 2013", Version="12.0.30110.00 Update 1"}
            }
        }
    };
    string json = JsonConvert.SerializeObject(pc);
    rtGenJsonFromClass.Text = json;
}

其輸出如下:

除了文字沒有排版, 還有一些欄位名稱被我改動過之外, 基本上其輸出和程式一的輸出是一模一樣的。

但是這兩者畢竟還是有所不同。最大的不同, 就是所有欄位都會有輸出值, 即使我根本沒有指定。例如 HardDisk1 的 Type 欄位, 我在程式六中並未指定這個值, 它仍然會輸出一個 "null"。換句話說, 若使用程式一, 那麼未指定的欄位就不可能有輸出值; 若使用程式六, 那麼未指定的欄位仍然會輸出一個預設值。

假設剛才的 Type 欄位不是 string 型別而是 int 型別, 那麼其預設值會變成 0; 如果型別是 int?, 那麼預設值便是 null。總之, 它一定會輸出一個值。

如果你不希望輸出這個預設值(null 或 0), 那麼你必須把程式做一點調整。把程式六中的 string json = ... 這一行改成如下兩行:

JsonSerializerSettings s = new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore };
string json = JsonConvert.SerializeObject(pc, s);

如此, 未指定值的欄位就不會被輸出了。

快速查詢

在上述各種做法中, 我們似乎都把情況想得太複雜。如果你只想要查詢 JSON 來源中的少數幾項資料, 我們非得建立類別, 再匯入不可嗎?

其實不需要。我們再拿 g0v 的 JSON 資料來舉例子好了。如果我們只需要從 JSON 來源讀取資料以用來展示, 不需要拿來做其它複雜的作業, 那麼我們可以直接使用 JObject 和 JArray 來進行處理:

// 程式七
private void QueryJsonNetDemo()
{
    string url = "http://api-beta.ly.g0v.tw/v0/collections/sittings";
    G0vSitting sittings = new G0vSitting();
    try
    {
        using (WebClient webClient = new WebClient())
        {
            webClient.Encoding = Encoding.UTF8;
            string json = webClient.DownloadString(url);
            JObject jo = JObject.Parse(json);
            int pageCount = (int)jo["paging"]["count"];
            rtQueryJson.Text += string.Format("總頁數: {0}\r\n\r\n", pageCount);
            JArray entries = (JArray)jo["entries"];
            int i = 0;
            foreach (JToken jt in entries)
                rtQueryJson.Text += 
                    string.Format("{0:00}. 會期: {1}, 編號: {2}, 開會次數: {3}\r\n主旨: {4}\r\n\r\n", 
                    ++i, (int)jt["session"], (int)jt["sitting"], ((JArray)jt["dates"]).Count, (string)jt["name"]);
        }
    }
    catch (Exception ex)
    {
        rtQueryJson.Text = ex.Message;
    }
}

在這個程式中, 讀取 JSON 的部份我們都知道了, 唯一的新東西就只有 JObject 和 JArray, 以及 JToken 這三個 JSON.NET 類別而已。其中的 JObject 就是 JSON 文件本身, JArray 是一個實作了 IList<JToken>, ICollection<JToken>, IEnumerable<JToken> 的集合類別。知道這個前題之後, 你應該很容易看懂這個程式的邏輯。

以下是執行結果:

結論

我在這篇入門文章裡介紹了 JSON 基本概念, 也介紹了 JSON 的輸出與輸入方法。如果你要做的事情很簡單, 那麼看完這篇入門文章, 應該已經足夠你接著往下發展了。

這裡有完整的專案檔案可以下載。執行環境如下:

  • Windows 7 X64
  • Visual Studio Professional 2013 Update 1

2014/5/11 註: 今天看到有讀者要求檔案分享的信件, 才知道原來我忘了把專案檔的共享權限改成公開, 實在很抱歉。我已經把該檔案改成可公開下載了。

沒有留言:

張貼留言