[C# 中的序列化與反序列化](.NET 源碼學習) 關鍵詞:序列化(概念與分析) 三種序列化(底層原理 源碼) Stream(底層原理 源碼) 反射(底層原理 源碼) 假如有一天我們要在在淘寶上買桌子,桌子這種很不規則不東西,該怎麼從一個城市運輸到另一個城市,這時候一般都會把它拆掉成板子,再裝到箱 ...
[C# 中的序列化與反序列化](.NET 源碼學習)
關鍵詞:序列化(概念與分析) 三種序列化(底層原理 源碼) Stream(底層原理 源碼) 反射(底層原理 源碼)
假如有一天我們要在在淘寶上買桌子,桌子這種很不規則不東西,該怎麼從一個城市運輸到另一個城市,這時候一般都會把它拆掉成板子,再裝到箱子裡面,就可以快遞寄出去了。這個過程就類似我們的序列化的過程(把數據轉化為可以存儲或者傳輸的形式)。當買家收到貨後,就需要自己把這些板子組裝成桌子的樣子,這個過程就像反序列的過程(轉化成當初的數據對象)。
序列化是指將對象轉換成位元組流,從而存儲對象或將對象傳輸到記憶體、資料庫或文件的過程。 它的主要用途是保存對象的狀態,以便能夠在需要時重新創建對象。反向過程稱為“反序列化”。有點類似於壓縮與解壓的過程。
【# 請先閱讀註意事項】
【註:
(1) 文章篇幅較長,可直接轉跳至想閱讀的部分。
(2) 以下提到的複雜度僅為演算法本身,不計入演算法之外的部分(如,待排序數組的空間占用)且時間複雜度為平均時間複雜度。
(3) 除特殊標識外,測試環境與代碼均為 .NET 6/C# 10。
(4) 預設情況下,所有解釋與用例的目標數據均為升序。
(5) 預設情況下,圖片與文字的關係:圖片下方,是該幅圖片的解釋。
(6) 文末“ [ # … ] ”的部分僅作補充說明,非主題(演算法)內容,該部分屬於 .NET 底層運行邏輯,有興趣可自行參閱。
(7) 本文內容基本為本人理解所得,可能存在較多錯誤,歡迎指出並提出意見,謝謝。】
【註:
1. 本文在此僅介紹序列化的使用方法及相關表層內容,礙於篇幅,源碼分析將在之後的文章中進一步介紹】
2. 本文每一個分析過程間的聯繫性可能較低,建議先閱讀總結部分,再閱讀正文
3. 此篇文章內容較為複雜,篇幅較大建議分段閱讀、先看總結再看內容】
一、序列化的作用與意義
先考慮壓縮與解壓。我們與一堆保存了信息的文件,現在需要將其通過網路發送給其他人。相信我們不會直接一個一個文件的傳,而是將其放在一個文件夾或作為一個壓縮包後在傳遞。這樣,即節省了空間,又加快了傳輸,同時將其打包後也讓我們在之後對這一堆文件有更好的管理。
- 傳輸。舉個例子,一座大廈好比一個對象,現在計劃要把這座大廈搬到另一個地方去,直接挪肯定不太現實。(一般地,網路傳輸只能通過位元組流,不能直接傳輸對象)。因此我們就把大廈拆成每一塊磚,給每塊磚定一個編號,知道這是在大廈的哪一部分。在這個過程中序列化就起到了將大廈分成磚頭的作用,方便數據的交互。
- 存儲。在某些程式運行時會產生一些對象,這些對象隨著程式的停止而消失,但如果我們想把某些對象保存下來,在程式終止運行後,繼續讓這些對象存在,可以使程式再次運行時讀取這些對象的值,或在其他程式中利用這些保存下來的對象。我們將這個過程命名為序列化。最常見的:Ctrl C / X,Ctrl V。
這時候就又有一個問題:為什麼要將其序列化後再讀寫而不直接對對象本身進行讀寫?
我們要將對象寫入一個磁碟文件,再將其讀出來,會產生什麼問題?其中一個最大的問題就是對象引用。再舉個例子,假設現在有兩個類,A 與 B。B類中含有一個指向A類對象的引用,現在我們對兩個類進行實例化 { A a = new A(); B b = new B(); },這時在記憶體中實際上分配了兩個空間,一個存儲對象a,一個存儲對象b。接下來我們將它們寫入到磁碟的一個文件中去,就在寫入文件時出現了問題。因為對象b包含對於對象a的引用,所以系統會自動的將a的數據複製一份到b,這樣的話當我們從文件中恢復對象時(也就是重新載入到記憶體中)時,記憶體分配了三個空間,而對象a同時在記憶體中存在兩份【註意:此處的複製指的是文件的複製,並非程式運行時的淺層複製,因此對於 a 會產生新的兩個無關對象】。此時,若想在文件上修改對象a的數據的話,就要搜索它的每一份拷貝來達到對象數據的一致性這樣增加了不少負擔。而序列化就解決了這樣的問題。
序列化的機制:
(1)保存到磁碟的所有對象都獲得一個序列號(1, 2, 3…)
(2)當要保存一個對象時,先檢查該對象是否被保存了。
(3)如果以前保存過,只需寫入與已經保存的具有序列號 k 的對象相同的標記;否則,保存該對象
利用編號的方法,解決了對象引用的問題,類似於程式設計中的復用。
小結,需要序列化的原因:
- 因為在網路傳輸時,一般只能使用數據流的形式,需要將對象轉換為便於傳輸形式。
- 某些情況下需要保存一些對象的特定情況,供其他時候使用。
二、基本序列化方式及其效率
使用 BinaryFormatter 進行串列化的二進位形式序列化(必須添加 System.Runtime.Serialization.Formatters.Binary; 命名空間);
使用SOAP協議進行的序列化;
使用 XmlSerializer 進行串列化的XML形式序列化對象;
JSON 序列化。
【註:如果一個類所創建的對象,能夠被序列化,那麼要求必須給這個類加上 [Serializable] 特性】
(一) 二進位序列化
需要引入命名空間
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
定義一個類,用於作為序列化的對象
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
定義待處理對象
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
定義一下序列化與反序列化方法
【思考:為什麼不能用 Line 46 行的語句?】
因為在類中,我們採用的是簡便屬性,且採用構造方法對欄位直接賦值。而簡便屬性似乎無法返回直接通過欄位賦值的欄位值(此推論和本人之前的映像不太相符,歡迎各位學者提出觀點)因此該對象的此屬性值恆為 null。
如果將屬性補全,則可以避免這樣的問題:
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
然而,運行的時候發現了問題:
- 由此得出一個結論:需要用 [Serializable] 特性修飾對應的類,否則無法將該類的對象序列化;但個人認為,應該是在不需的地方加上NonSerialized才更合理。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
我們為這個類加上相應標簽再來跑一次
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
序列化後文件中的內容:
在程式所在的相關的文件夾內生成了一個 .bin 類型的文件,說實話我有點看不懂它為什麼要存儲成這樣的形式(不排除我的編碼類型導致的問題),理論上應該是以二進位的方式呈現數據。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
反序列化後的結果:
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
從剛纔得出的結論再入手,那我們可不可以指定某些元素不讓其序列化呢?答案是可以的
只需要在相應元素前加上這個特性即可。
看看效果:
可以發現,因為沒有序列化欄位 age,因此文件中也沒有了 age 的身影;反序列後輸出了 int 類型的預設初始值。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
既然有三種序列化的方式,那當然要比較一下其性能。
為了較好的得出能效差異,此處採用4個對象進行序列化與反序列化操作,每個對象包含 1e7(實測為該狀態下本人電腦的極限值) 個其他對象,這些對象中每個包含兩個欄位,如下圖:
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
結果:(運行時間與生成文件的大小)
由於每次進行一個周期均會覆蓋原序列化的文本,因此此處的文件大小,僅代表一個周期(一次序列化 + 一次反序列化)生成的文件大小,即 1e7 的對象數量。
(二) XML 序列化
首先簡單介紹一下 XML 格式。
可擴展標記語言( eXtensible Markup Language,標準通用標記語言的子集)是一種簡單的數據存儲語言。使用一系列簡單的標記描述數據,而這些標記可以用方便的方式建立,雖然可擴展標記語言占用的空間比二進位數據要占用更多的空間,但可擴展標記語言極其簡單易於掌握和使用。
總結一下特點:利用更簡單的一些標記去描述數據,使得數據使用更加方便,用空間換取便捷。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
需要引入命名空間
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
還是用那個類,定義一下序列化與反序列化方法
可以發現,二者在格式上其實差別不大,過程均是確定文件、序列化或反序列化、寫入或讀取。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
簡單看一下效果
但調試過程中發生了錯誤:
註意看此處的報錯,“Only public types can be processed” 也就是說,只有公共類型,才能被 xml 序列化。因此,需要將類 Person 標記為 public。
不過對於 XML 序列化,並不需要將序列化對象標記為 [Serialize]。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
結果如下:
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
不知道各位有沒有註意到一個問題
二進位序列化:
Xml 序列化:
對比可以發現,二進位序列化時訪問的是對象的欄位;Xml 序列化時訪問的是對象的屬性。所以當使用簡便屬性,且通過構造方法直接對欄位賦值時,因為無法通過屬性獲取到欄位的值,因此在進行 Xml 序列化時會出現異常:
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
同樣,來測試一下性能:
同理,由於每次進行一個周期均會覆蓋原序列化的文本,因此此處的文件大小,僅代表一個周期(一次序列化 + 一次反序列化)生成的文件大小,即 1e7 的對象數量。
可以看到,相較於二進位序列化,Xml在時間上明顯減少,但消耗了接近兩倍的空間,頗有一種空間換時間的感覺。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
(三) 基於 SOAP 協議的序列化
SOAP 和在操作上二進位流序列化差別不大;結果上和 Xml 差別不大,只是 SOAP 不能序列化泛型對象,因此在序列化時要將待序列化的對象轉換成數組形式。。
先來介紹一下 SOAP 協議:SOAP 是基於 XML 的簡易協議,可使應用程式在 HTTP 之上進行信息交換。更簡單地說:SOAP 是用於訪問網路服務的協議。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
【註:由於無法載入命名空間 System.Runtime.Serialization.Formatters.Soap ;微軟文檔也沒有查找到相關信息,因此在此不作演示】
(四) JSON 序列化
JSON(JavaScript Object Notation, JS對象簡譜)是一種輕量級的數據交換格式。它基於 ECMAScript(European Computer Manufacturers Association, 歐洲電腦協會制定的js規範)的一個子集,採用完全獨立於編程語言的文本格式來存儲和表示數據。簡潔和清晰的層次結構使得 JSON 成為理想的數據交換語言。 易於人閱讀和編寫,同時也易於機器解析和生成,並有效地提升網路傳輸效率。【百度百科 JSON_百度百科 (baidu.com)】
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
需要引入命名空間
據微軟的說法:
後續在學習源碼時,會進一步分析二者異同。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
定義序列化與反序列化方法
可以發現,其無需初始化用於序列化的對象,推測應該是方法在該類中被定義為 static。這樣的方式使得使用更加便捷,也在一定程度上節省了空間。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
結果展示
其和 Xml 也是一樣,讀取對象的屬性而不讀取欄位。且存儲本質為字元串,非常簡潔。這也為其高效傳輸與廣泛應用奠定了基礎。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
性能測試:
可以看到,單從表象,JSON 序列化幾乎整合了二進位序列化和 XML 序列化的優點:不僅生成的文件體積小、周期運行速度也快。
總結
1. 序列化是一種處理數據的方式,將代碼中的對象或元素轉化為某種具有意義和規律的流形式(文本流,字元串流等),便於進行存儲、分析與傳輸。
2. 序列化主要用在數據持久化和遠程調用。把對象狀態保存到流中,達到持久化(或遠程調用)的作用,比如有一個類有100個屬性欄位,如果在其他地方使用這個被實例化的類就必須讀取100次它的屬性以獲取對象的狀態信息,才能利用這些信息構建新類。而有了序列化就可以將類信息保存到一個流中,要構造新類時候直接反序列化,將所有屬性直接付給新實例。這比手工寫代碼讀取屬性方便,還實現了持久化。
3. 三種序列化的對比:
(1)二進位流序列化:
性能測試結果:時間 101582.1859 ms,空間 228 MB * 4。
需要對序列化對象進行特性 [Serialize] 標記。
- 優點:對數據的保真度很高,對於多次調用應用程式時保持對象狀態非常有用。例如,通過將對象序列化到剪貼板,可在不同的應用程式之間共用對象;將對象序列化到流、磁碟、記憶體和網路等;遠程處理使用序列化;“按值”在電腦或應用程式域之間傳遞對象。
- 缺點:
a) 如果使用不同的 .NET 版本序列化和反序列化以 UTF-8 或 UTF-7 編碼的對象,則不保留該對象的狀態。即,在不同框架與編碼類型下,可能會產生衝突異常或不保存對象。
b)序列化/反序列化所用時間較長,且序列化內容不易被直接看懂。
(2)XML 序列化:
性能測試結果:時間 43889.8765 ms,空間 476 MB * 4。
需要將對象進行標記為 public。
- 優點:
a) 相較於二進位流序列化,在時間效率上有所提升。
b) 序列化結果具有一定可讀性。
c) 基於其衍生出的 SOAP 協議序列化方式,具有安全性、可擴展性、跨語言、跨平臺以及支持多種傳輸形式等優點。
d) 只序列化公共屬性和欄位,當希望提供或使用數據而不限制使用該數據的應用程式時,這一點非常有用。由於 XML 是開放式的標準,因此它對於通過 Web 共用數據來說是一個理想選擇;SOAP 同樣是開放式的標準,這使它也成為一個理想選擇。
- 缺點:由於採用大量標記去標識每個對象,使得序列化結果冗長複雜,對空間的額外開銷增大。
(3)JSON 序列化:
性能測試結果:時間 24381.7978 ms,空間 267 MB * 4。
- 優點:
a) 整合了二進位序列化占用空間小與 XML 序列化速度快的優點。
b) 序列化結果具有極佳的可讀性與簡潔性。
c) 相對於 XML 協議解析速度更快。
d) 只序列化公共屬性,且JSON 是開放式的標準,對於通過 Web 共用數據來說是一個理想選擇。
- 缺點:
a) 沒有統一可用的 IDL(Interface description language 介面描述語言)即,跨平臺介面,延長了開發周期。
b) 在某些語言中需要採用反射機制,不適用於 ms 級響應。
三、三種序列化方式的實現原理
【註:由於關於該部分源碼分析的內容與資料較少,且本人水平有限,不能闡述得很清晰或完全正確,還請各位讀者指正與提出意見,謝謝】
(一) 二進位序列化 BinaryFormatter
1. 基本信息
位於程式集 System.Runtime.Serialization.Formatters.dll,命名空間 System.Runtime.Serialization.Formatters.Binary 中。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
密封類,繼承了介面 IFormatter。該介面包含兩個方法 Serialize() 與 Deserialize(),主要用於提供格式化串列化對象的功能,在不同情況下根據需要覆蓋介面中的方法,以達到多態的目的。
該介面專門用於定義具體的序列化和反序列化方式
- Line 14:返回值類型為 object,參數為 Stream 類型的反序列化方法。【有關 Stream 會在文末進行補充說明】
- Line 19:無返回值,參數為 Stream 類型與 object 類型的序列化方法。
- Line 13、17:註意到這兩個方法均被標記為 Obsolete(過時的),也就是說出於某種原因,這種方法已被廢棄,存在某些更新的方法代替。
- Line 24:類型為 ISurrogateSelector 屬性 SurrogateSelector。其中,介面 ISurrogateSelector 的作用是幫助格式化程式選擇代理以委托給其他對象的序列化或反序列化。
解釋一下,為了使序列化/反序列化機制工作起來,需要定義一個”代理類型”,它接受對現有類型進行序列化和反序列化的操作。在正式執行前,先向格式化器記錄該代理類型的一個實例,告訴格式化器,代理類型要作用於現有的哪一個類型。格式化器檢測到它正要對現在類型的一個實例進行序列化和反序列化時,會調用由該代理對象定義的方法。
【註:具體運行流程將在後文分析】
- Line 29:類 SerializationBinder,允許用戶控制類的載入並指定要載入的類,用於控制在序列化和反序列化期間使用的實際類型。
在序列化過程中,格式化程式傳輸需要創建正確類型和對應版本的對象實例的信息,通常包括對象的完整類型名稱和程式集名稱。預設情況下,反序列化可使用此信息創建相同對象的實例。由於原始類可能在執行反序列化的電腦上不存在,如:原始類已在程式集之間移動,或者伺服器和客戶端要求使用不同的類版本,因此有些用戶可能需要控制要序列化和反序列化哪個類。
在創建和記錄信息時有兩種方式:
(1)BindtoName(),記錄對象的類型(Type),返回對象所在的的程式集名(assemblyName)與所屬的類型名稱(typeName)。
(2)BindToType(),記錄對象所在的的程式集名(assemblyName)與所屬的類型名稱(typeName),返回對象的類型(Type)。
- Line 34:結構體 StreamingContext,用於說明給定序列化流的源和目標,並提供另一個調用方定義的上下文。簡單來說就是添加一些信息,是的數據的來源去向清晰化。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
1 個只讀變數和7 個欄位
- Line 210:類 ConcurrentDictionary<TKey,TValue> 表示可由多個線程同時訪問的鍵/值對的線程安全集合。其中,所有公共成員和受保護成員 ConcurrentDictionary<TKey,TValue> 都是線程安全的,並且可以從多個線程併發使用。但是,通過實現(包括擴展方法) ConcurrentDictionary<TKey,TValue> 之一訪問的成員不能保證線程安全,並且可能需要由調用方同步。
- Line 213:介面 ISurrogateSelector 指示序列化代理項選擇器類。代理項選擇器實現 ISurrogateSelector 介面,以幫助格式化程式選擇代理以委托給其他對象的序列化或反序列化。有關代理器更多內容,之後會提到。
- Line 216:結構體 StreamingContext 說明給定序列化流的源和目標,並提供另一個調用方定義的上下文。主要用於信息的存儲,包括但不限於序列化前後的對象內容。
- Line 219:類 SerializationBinder 允許用戶控制類載入並指定要載入的類。主要配合代理選擇器使用,加上版本容錯機制,可以在一定程度上實現不同版本間的序列化與反序列化操作。
- Line 222、225、228:此處的三個枚舉在後文均有提及,在此不做介紹。
- Line 213:一個類型為 object 的數組,用於存儲序列化後的結果,作為一份“備份”記錄結果,供反序列化時使用。
2. 序列化流程
- Line 179:參數 serializationStream 表示待序列化的數據流類型(主要包括:文件流 FileStream、記憶體流 MemoryStream、網路流 NetworkStream、加密流 CryptoStream、文本讀寫 StreaReader 與 StreamWriter、二進位讀寫 BinaryReader 與 BinaryWirter);graph 表示待序列化的對象。
- Line 181:用於判斷當前狀態下能否進行二進位序列化。據微軟的說法,由於存在安全漏洞,該方法現已過時,並生成 ID 為 SYSLIB0011 的編譯時警告。此外,在 .NET 7 及 ASP.NET Core 5.0 及更高版本的應用中,除非 Web 應用已重新啟用 BinaryFormatter 功能,否則它們會引發 NotSupportedException 的異常(詳細內容請參閱 中斷性變更:BinaryFormatter 序列化方法已過時,並且已在 ASP.NET 應用中禁用 - .NET | Microsoft Learn)。
- Line 185:如果待序列化對象為空,則不能進行序列化操作。
- Line 189:定義格式化枚舉並賦值,為後續的序列化做準備。
其中,類 InternalFE,內部存儲了 4 類枚舉
(1)FormatterTypeStyle 表示在序列化流中的佈局格式
其中,TypesWhenNeeded 表示格式只能為對象數組、Object類型與 ISerialized 非基元值類型所聲明的類型;TypesAlways 表示格式可以為所有對象成員和 ISerializable 對象成員;XsdString 表示可以採用 XSD(XML Schema Definition)格式(而不是 SOAP 格式)來提供字元串。
(2)FormatterAssemblyStyle 用於定位和載入程式集的方法,一定程度上規定了相容性的問題。
Simple 表示在簡單模式下,反序列化期間所用的程式集不需要與序列化期間所用的程式集完全匹配。具體而言,當 LoadWithPartialName 方法載入程式集時,版本號不需要匹配。
Full 表示在完全模式下,反序列化期間所用的程式集必須與序列化期間所用的程式集完全匹配;使用 Assembly 類的 Load 方法載入程式集。
(3)TypeFilterLevel 指定用於 .NET Framework 遠程處理的自動反序列化的級別,一定程度上規定了能進行處理的數據類型。
Low = 2,表示 .NET Framework 遠程處理的 Low (低)反序列化級別,支持與基本遠程處理功能相關聯的類型。
Full,表示 .NET Framework 遠程處理的 Full (完全)反序列化級別,它支持遠程處理在所有情況下支持的所有類型。
(4)InternalSerializerTypeE指定需要進行的序列化類型。
- Line 197:開始進行序列化,並記錄日誌。
- Line 198:定義一個對象寫入器,並傳入參數包括代理類型、上下文信息、格式化器枚舉、序列化/反序列化所控制的實際類型。
- Line 199:定義二進位寫入器,並傳入參數包括待序列化的數據流類型、對象寫入器、序列化流中的佈局格式。
- Line 200:調用對象寫入器中的序列化方法。【這一步才是真正的開始序列化】
- Line 205:序列化結束,並記錄日誌。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
下麵分析一下 Line 200 處的詳細過程:
- Line 32:開始寫入。
- Line 38:獲取一個特殊的 ID 編號。【註:該方法內部涉及很多其他方法的調用,在此不一一分析,僅對過程做出說明】
首先對方法 InternalGetId() 傳入參數:待序列化對象、是否將唯一 ID 分配給值類型、對象類型的信息、是否新對象(此處的“新”值得是該對象在之前是否進行過序列化操作)。
Line 556:若該對象是之前(已經進行過序列化)的對象,則直接返回其先前序列化後被分配的 ID。
Line 562:若該對象在之前沒有進行過序列化操作,且描述對象信息不為空、沒有被分配過唯一的 ID,則為該待序列化對象計算一個唯一的 ID。
Line 571:若該對象在之前沒有進行過序列化操作,但出於某種原因無法計算新的 ID,則調用一個上層類(ObjectIDGenerator)中的公共方法,以獲得 ID。
Line 59:方法 FindElement(),元素定位,利用元素的哈希值在數組 _objs 中查找待序列化對象 obj,並返回其所在位置以及是否存在的標誌(flag)。
Line 61 ~ 78:若未找到相應對象,則將其記錄至數組中,並計算相應 ID;否則直接返回其對應的 ID。(此處的 ID 是根據對象的哈希值得出,類似於“記憶化搜索”,記錄已經處理過的對象,以便後續直接使用)。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
- Line 40:方法 WriteSerializedStreamHeader(),初始化序列化寫入流的起始器。
- Line 41:將待序列化對象加入到準備隊列中。
- Line 44:方法 GetNext(),依次從準備隊列中取出元素與其對應的 ID,直到隊列為空。
- Line 47~55:將隊列中的元素轉換為 WriteObjectInfo 類型,該類型數據流 Stream 類型中的一種,主要用於流的寫入。
- Line 57:類型 NameInfo,記錄對象的詳細信息,包括以下內容:
- Line 58:正式開始進行寫入。
Line 78:objectInfo 待寫入的對象;memberNameInfo 與 typeNameInfo 傳入的為同一個內容,存儲了對象的詳細信息。
Line 87:Converter.s_typeofString,相當於字元串類型。若待序列化對象為字元串類型,則以字元串的形式進行寫入。
Line 93:若待序列化對象為數組類型,則以數組的形式進行寫入。
【礙於篇幅,在此對於方法 WriteObjectString() 與 WriteArray() 就不放出源碼,僅做簡單說明】
對於方法 WriteObjectString(),首先處理 Null 的部分。該過程根據對象中的 Null 數量,將所有 Null 進行處理,確保在之後的寫入中遇到 Null 時不會觸發異常 NullReference,Null 處理完後再對剩餘部分進行序列化。整個序列化過程由方法 WriteByte()、WriteInt32() 與 WriteString() 完成,其作用是將一個位元組/整數/字元串寫入文件流中的當前位置。
對於方法 WriteArray(),通過遍歷的方式,說簡單些就是依次將數組中的每個元素轉換後寫入文件流。
Line 101:若待序列化元素既不是字元串類型,也不是數組類型,則獲取對象在緩存 cache 中的名稱、類型以及數據本身,分別存儲到數組 array、array2 與 array3 中。在初始化時已經將對象內部的個元素信息分別存儲到了類的欄位中,在此處進行賦值。其按照訪問每個元素的方式,將每個元素的信息存儲到數組中,這樣做的原因可能是同一個對象中可能存在不同類型的元素,需要以不同方式進行序列化。
Line 102:若對象可以進行序列化操作,則標記並記錄信息供後續使用。
Line 112:獲取類型。
Line 113:將該類型 type 轉換為某種編碼,判斷其是否為基元類型 && 判斷其是否不為字元串類型。
Line 115~124:若元素不為空,則將元素操作後存儲與數組 array4 中;否則根據元素類型,將操作後的信息存儲於數組 array4 中。
至此,初步轉換已經完成,之後再根據 array4 中的信息,將對象的每個元素寫入 BinaryObjectWithMap 類型的遍歷中,並添加到 _objectMapTable,最終再根據 FileStream 寫入文件。
小結一
1. 總結一下二進位序列化的流程:將待序列化對象分解為最小單元並獲取其類型,依次遍歷最小單元併在數組中存儲其相關信息,將其寫入數據流中,並複製一份結果存儲在數組中。
2. 二進位序列化過程比較複雜,其需要針對每一位不同的元素類型以及出現的位置,將其轉換為能夠保存這些信息的二進位碼,因此存在許多遍歷於轉換,效率較低。同時這也導致了反序列化的效率較低。雖然電腦對二進位數處理有著天然的優勢,但是在進行轉換與逆轉換的時候效率確實不高。
3. 根據自然規律,越少的表示單元就需要越多的組合來表示一個信息,二進位碼只有 0 與 1 兩種單元,其需要儲存元素類型、位置、狀態及其他內容,使得一個元素需要轉換出很長的一串二進位碼,使得空間占用過多。
4. 二進位反序列化的時候會自動相容處理序列化一方新增的數據。但是在個別情況下會出現反序列化的過程中遇到異常的情況。目前發現的出現反序列化異常的數據類型包括,泛型集合與數組。這兩種數據結構並非是一定會導致二進位反序列化報錯,而是有一定的條件。泛型集合出現反序列化異常的條件有三個:
(1)序列化的對象新增了泛型集合;
(2)泛型使用的是新增的類;
(3)新增的類在反序列化的時候不存在;
數組也是類似的,只有滿足上述三個條件的時候,才會導致二進位反序列化失敗。
具體原因可能與其版本容錯機制(Version Tolerant Serialization,VTS)有關。詳細內容請參閱(Version tolerant serialization | Microsoft Learn)
5. 據微軟官方的說法:
究其原因是:其會不安全地處理請求有效負載的威脅類別,可導致目標應用內出現拒絕服務 (DoS)、信息泄露或遠程代碼執行。其中的 Deserialize() 方法可用作攻擊者對使用中的應用執行 DoS 攻擊的載體。這些攻擊可能導致應用無響應或進程意外終止。且使用 SerializationBinder 或任何其他 BinaryFormatter 配置開關都無法緩解此類攻擊。.NET 認為此行為是設計使然,因此不會發佈代碼更新來修改此行為,所以微軟不建議使用二進位序列化。(感興趣的讀者可以深入研究,在此不作更多解釋)
當然,二進位序列化還是有一些優點:
6. 數據保密性強。這一點和可閱讀性是相反的,可閱讀性低則保密性強。
7. 序列化後的文件,由於時二進位形式,因此便於電腦直接分析與操作。
(二) XML 序列化
1. 基本信息
位於程式集 System.Private.Xml.dll,命名空間 System.Xml.Serialization 中。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
Xml 沒有繼承任何類以及介面,通過自定義序列化與反序列化方法,與很多重載方法,實現一種新的序列化形式。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
共 11 個欄位
- Line 930:類 TempAssembly,與類 Assembly 關聯,基於反射可以獲得正在運行的裝配件信息,也可以動態的載入裝配件,以及在裝配件中查找類型信息,並創建該類型的實例。可以使用反射動態地創建類型的實例,將類型綁定到現有對象,或從現有對象中獲取類型,然後調用其方法或訪問器欄位和屬性。
【註:有關反射 Reflection 會在文末補充說明】
- Line 933:欄位 _typedSerializer,表示對象之前是否已經進行過 XML 序列化操作。
- Line 936:抽象類Type,用來包含類型的特性,使用這個類的對象能讓我們獲取程式使用的類型的信息。
補充一些關於這個類的信息:
(1)對於程式中用到的每一個類型,CLR都會創建一個包含這個類型信息的Type類型的對象。
(2)程式中用到的每一個類型都會關聯到獨立的Type類型的對象。
(3)不管創建的類型有多少個實例,只有一個Type對象會關聯到所有這些實例。
- Line 939:抽象類 XmlMapping,支持 .NET 類型和 XML 架構數據類型之間的映射,相當於是一種規則,用於序列化與反序列化的正常進行。
- Line 942:結構體 XmlDeserializationEvents,包含可用於將事件委托傳遞給 Deserialize 的線程安全的 XmlSerializer 方法的欄位。
- Line 945:欄位 DefaultNamespace,獲取預設命名空間的命名空間 URI(Uniform Resource Identifier 標識、定位任何資源的字元串),如果沒有預設命名空間,則為空字元串。
- Line 948:與 Line 936 處的欄位為同一類型,推測 _primitiveType 表示對象的基元類型(16種),_rootType 表示對象派生於的類型(System.ValueType、System.Enum、System.Object)。
- Line 951:欄位 _isReflectionBasedSerializer 表示對象是否是基於反射而實現序列化。
- Line 954:類 TempAssemblyCache,存儲對象在緩存內的信息,包括但不限於:數據類型、反射信息。
- Line 957:類 XmlSerializerNamespaces,包含 XmlSerializer 用於在 XML 文檔實例中生成限定名的 XML 命名空間和首碼。
- Line 960:定義字典,以類型為 Key,記錄映射關係(XmlMapping)與 序列化器。
2. 序列化流程
- Line 278:參數
(1)xmlWriter 一個寫入器,提供一種快速、非緩存和只寫入方式以生成包含 XML 數據的流或文件;
(2)o 表示待序列化對象;
(3)namespace 包含 XmlSerializer 用於在 XML 文檔實例中生成限定名的 XML 命名空間和首碼;
(4)encodingStyle 對象的編碼類型,包括但不限於 UTF8,Unicode,ASCII。
(5)id 是記錄同一對象的唯一標識符。
- Line 288:若對象為基元類型,且具有一定的編碼類型,則按照基元類型的操作進行序列化。
註意到,除了基元類型外,還包括其他 4 種類型,也被歸於初始類型(primtiveType)。
其中的 Write_xxx() 方法,此處以 Write_string 為例:
其內部的語句以及調用的方法,對文件寫入後,就是我們在文件中看到的內容,寫入的內容包括編碼類型、對象與其內部元素的數據類型、元素間的關係、對象當前狀態等。礙於篇幅,在此不作展開。
- Line 292:若對象不是基元類型 + 額外增添的 4 種類型,且是基於或需要使用反射的,則利用反射進行序列化。
類 ReflectionXmlSerializationWriter,派生自類 XmlSerializationWriter,該基類有兩個子類,另一個是 XmlSerializationPrimitiveWriter,也是用來進行序列化操作。由此可知,基類 XmlSerializationWriter 相當於用來提供不同實現形式的序列化器。
對於 XmlMapping,其原理類似於字典的形式,將不同類型的元素與序列化方式一一對應做出映射,根據映射規則執行不同的序列化操作與反序列化操作。
- Line 296:若對象有關反射的信息為 null 或在此之前已經進行過 Xml 序列化操作,則定義一個新的並利用現有的信息直接初始化序列化器。如果內部元素不為空,則轉到標簽 IL_D6,否則執行方法 InvokeWriter(),該方法是一種基於 Xml 的 Soap 的序列化方法。
- Line 322:方法 Flush(),把寫在緩衝區的內容寫入文件,清理當前編寫器的所有緩衝區,並使所有緩衝數據寫入基礎流。
區別於方法 Close():暫時關閉。關閉當前流並釋放與之關聯的所有資源(如套接字和文件句柄)。不直接調用此方法,而應確保流得以正確釋放。
區別於方法 Dispose():清理記憶體。釋放某一對象使用的所有資源。Dispose 會負責 Close 的一切事務,額外還有銷毀對象的工作,即Dispose包含Close。
一般我們使用 StreamWriter 等類時,先調用 Flush() 將數據寫入文件,再調用 Dispose() 銷毀流對象。
反序列化過程區別不大,對不同數據類型採用不同的方法,最後返回一個類型為 object 的對象。
小結二
1. 總結一下 Xml 序列化的流程:根據對象的不同類型,採取不同的標記方式,並寫入文件;反序列化就直接從字元串中讀取買個標記塊並恢復為對象。
2. 其因為不需要對結果進行複製儲存操作,因此在效率上比二進位更快;但由於對對象中的每一個元素都要進行相應的字元串標記,因此生成的文件會大很多,這也導致了在傳輸過程中浪費資源。
3. 雖然其生成的結果文件很大,但其可指定元素或特性的名稱,且文件可讀性高,以及對象共用和使用的靈活性。XML 序列化將對象的公共欄位和屬性或方法的參數和返回值序列化成符合特定XML格式的流,只要生成的XML流符合給定的架構,則對於所開發的應用程式就沒有約束。
4. 不過,其不如二進位序列化更廣泛。。序列化數據只包含數據本身以及類的結構,不包括類型標識和程式集信息;類必須有一個將由 XmlSerializer 序列化的預設構造函數,且只能序列化公共屬性和欄位,不能序列化方法、索引器、私有欄位或只讀屬性(只讀集合除外)。
(三) JSON 序列化
1. 基本信息
位於程式集 System.Text.Json.dll,命名空間 System.Text.Json 中。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
同樣沒有繼承任何類與介面,也是通過自定義序列化與反序列化方法,進行多次重載。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
共 6 個欄位
這六個欄位均為內部只讀欄位,用於在不同情況下,選用不同的標識,以完成相應的序列化操作。
- Line 2089、2097、2106:PropertyName 直譯是“屬性名稱”。
- Line 2118、2121、2124:metadata 直譯是“元數據”。其中,結構體 JsonEncodedText 提供將 UTF-8 或 UTF-16 編碼文本轉換為適用於 JSON 的表單的方法,此類型可用於緩存和存儲用於提前編寫 JSON 的已知字元串,方法是預先對其進行編碼。Encode() 方法是將指定類型的文本轉換為 JSON 字元串,即序列化後的結果表現形式。
根據欄位的首碼可以推測 s_id 表示給對象的唯一標識符;s_ref 表示引用地址;s_values 表示對象值。
2. 序列化流程
【註:由於存在多個重載方法,此處分析的是前文(第二部分第(四)點 JSON 序列化)所調用的序列化方法】
- Line 3:先來看看這個特性 RequiresUnreferencedCode 剪裁警告。
剪裁:將打包的應用取出某一部分,單獨使用。
在發佈應用程式時,.NET SDK 會分析整個應用程式並刪除所有未使用的代碼。但可能很難確定什麼是未使用的,或者更準確地說是使用了什麼。為了防止剪裁應用程式時行為發生變化,.NET SDK 通過“剪裁警告”提供剪裁相容性的靜態分析。當剪裁器發現可能與剪裁不相容的代碼時,剪裁器會生成剪裁警告。 與剪裁不相容的代碼可能會在剪裁後的應用程式中產生行為變更,甚至崩潰。理想情況下,所有使用剪裁的應用程式都不應有剪裁警告。如果有任何剪裁警告,則應在剪裁後徹底測試應用,以確保沒有行為變更。
- Line 4:註意到該方法為泛型方法,其中泛型類型可空。
- Line 4:utf8Json 表示序列化後輸入輸出的流數據類型(在之前的演示中,採用的是文件流 FileStream);value 為待序列化對象;options 表示 Json 序列化操作的某些特定選項,預設為 null。
- Line 10:類 Type 在之前提到過,用於存儲對象的相關信息。
該方法用於將對象轉換為某種特定的統一類型,以便後面序列化使用。
- Line 11:類 JsonTypeInfo,提供有關類型的 JSON 序列化相關元數據。方法 GetType() 根據不同的 options 針對剛纔轉換後的對象 runtimeType 獲取其內部詳細信息。
- Line 12:正式開始序列化。
- Line 1923:類 JsonSerializerOptions,提供與 JsonSerializer 一起使用的選項。此處獲取 JsonSerializerOptions 與當前JsonTypeInfo實例關聯的值。
- Line 1924:結構體 JsonWriterOptions,允許用戶在使用 Utf8JsonWriter 編寫 JSON 時定義自定義行為。此處保存 options 中對於寫入的行為規則(即,方式)。
- Line 1925:類 PooledByteBufferWriter,繼承了介面 IBufferWriter<byte>,表示可以向其中寫入byte 數據的一個輸出接收器;初始化大小為預設緩衝器 Buffer 的大小,其中,具體值為整型16384。
- Line 1927:類 Utf8JsonWriter,提供高性能的 API,以便提供 UTF-8 編碼 JSON 文本的只進和非緩存編寫許可權。以無緩存的形式順序寫入文本,預設情況下遵循 JSON RFC,但編寫註釋除外。此處,使用要寫入輸出的指定流和自定義選項初始化 Utf8JsonWriter 類的新實例。
- Line 1929:結構體 WriteStack,相當於一個寫入器,將待序列化元素依次通過流寫入文件。
- Line 1930:類 JsonConverter,用於將對象或值轉換為 JSON,或是從 JSON 轉換為對象或值。此處存儲寫入器的初始狀態,將待序列化對象放入棧中。
- Line 1934:根據緩衝器的容量計算出一個標稱值,表示當前棧頂的對象(當前待序列化的對象)。
- Line 1935:判斷當前棧是否為空,是否可以繼續進行序列化。
- Line 1936:以 utf8 的形式將當前對象進行序列化。
- Line 1937:清空當前臨時變數中的對象,獲取下一個對象,繼續重覆直到棧中沒有元素。
小結三
1. 總結流程:判斷是否有特殊需求(options),獲取信息,壓入棧依次遍歷寫入流。
2. 其不需要大量的註釋性字元串,只保留關鍵信息。因此數據格式比較簡單, 易於讀寫, 格式都是壓縮的, 占用帶寬小;文件大小比 XML 序列化小很多,和二進位序列化差別不大。
3. 時間方面,其不需要像二進位序列化一樣進行過長的前搖以及頻繁的數組複製,因此時間上比較快,但對數據的描述性比XML較差。
4. 對於二進位序列化和 XML,其實生成的結果更加易讀、更便於肉眼檢查。
5. JSON 格式支持多種語言;能夠直接為伺服器端代碼使用,大大簡化了伺服器端和客戶端的代碼開發量,但是完成的任務不變,且易於維護。
6. 目前,在 C# 中JSON 序列化有三種形式使用 DataContractJsonSerialize r類、使用 JavaScriptSerialize r類、使用 JSON.NET 類庫。具體詳細信息在此暫不做解釋,在此僅簡要說明三種方式優缺點:
(1)DataContract 和 Newtonsoft.Json 這兩種方式效率差別不大,隨著數量的增加 JavaScriptSerializer 的效率相對來說會低些,反序列化和其他兩種相差不大。。
(2)對於 DataTabl e的序列化,如果要使用 Json 數據通信,使用 Newtonsoft.Json 更合適;如果是用 XML 做持久化,使用 DataContract 合適。
(3)在容錯方便,還是 Newtonsoft.Json 比較強。
【有關 C# 中的 Stream】
【參考文獻:Stream 類 (System.IO) | Microsoft Learn && C# 溫故而知新:Stream篇(—) - 逆時針の風 - 博客園 (cnblogs.com)】
【註:礙於篇幅在此僅對該內容作簡要說明,更多詳細內容請參閱 Stream 類 (System.IO) | Microsoft Learn 】
一、相關基礎概念
1. 流:提供位元組序列的一般視圖。
2. 位元組序列:位元組對象都被存儲為連續的位元組序列,位元組按照一定的順序進行排序組成了位元組序列。
那麼流就可以稱為:供位元組序列流動的通道。在程式中反應為將對象排列起來,順序流向(放到)記憶體、文件等地方。
二、 類 Stream
一個抽象類,繼承了類 MarshalByRefObject,兩個介面 IDisposable,IAsyncDisposable。
其中,類 MarshalByRefObject 用於允許在支持遠程處理的應用程式中跨應用程式域邊界訪問對象,簡單來說是跨區域訪問的;介面 IDisposable 用於自動析構對象,自動釋放非托管資源;IAsyncDisposable 提供一種用於非同步釋放非托管資源的機制。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— —— ——
(一) 八個屬性
1. 只讀的 Can 家族
CanRead:判斷該流是否能夠讀取;
CanSeek:判斷該流是否支持跟蹤查找;
CanWrite:判斷當前流是否可寫;
CanTimeOut 獲取一個值,該值確定當前流是否可以超時,如果網路連接中斷或丟失,會超時;如果要實現的流必須能夠超時,則應重寫此屬性以返回 true。
2. Length
表示流的長度(以位元組為單位)。
3. Position