[C# 中的序列化与反序列化](.NET 源码学习)
[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(JavaScriptObject 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
位于程序集 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
获取或设置当前流中的位置。
虽然从字面中可以看出这个 Position 属性只是标示了流中的一个位置而已,可是在实际开发中会发现,在很多ASP,NET 项目中上传文件或图片时,会经历过这样一个痛苦:Stream对象被缓存了,导致了 Position 属性在流中无法找到正确的位置,因此每次使用流前必须将 Stream.Position 设置成0,但是这还不能根本上解决问题,最好的方法就是用 Using 语句将流对象包裹起来,用完后关闭回收即可。
4. Timeout 家族
获取或设置一个值(以毫秒为单位),该值确定流在超时前将尝试读取/写入的时间,如果流不支持超时,则此属性应引发异常。
(三) 常用方法
1. Write
向当前流中写入字节序列,并将此流中的当前位置提升写入的字节数。(依次写入)
buffer 数组表示此方法将 count 个字节从 buffer 复制到当前流;
offset 表示 buffer 中的从零开始的字节偏移量,从此处开始将字节复制到当前流;
count 为要写入当前流的字节数。
ReadOnlySpan<Byte> buffer 表示一个内存的区域,此方法将此区域的内容复制到当前流。
2. Read
从当前流读取字节序列,并将此流中的位置提升读取的字节数。(依次读取)
buffer 数组,当此方法返回时,此缓冲区包含指定的字符数组,此数组中 offset 和 (offset + count - 1) 之间的值被从当前源中读取的字节所替换;
offset 表示 buffer 中的从零开始的字节偏移量,从此处开始存储从当前流中读取的数据;
count 要从当前流中最多读取的字节数。
ReadOnlySpan<Byte> buffer 表示一个内存的区域,当此方法返回时,此区域的内容将替换为从当前源读取的字节。
3. Seek
设置当前流中的位置。
还记得Position属性么?其实Seek方法就是重新设定流中的一个位置:如果 offset 为负,则要求新位置位于 origin 指定的位置之前,其间隔相差 offset 指定的字节数;如果 offset 为零,则要求新位置位于由 origin 指定的位置处;如果 offset 为正,则要求新位置位于 origin 指定的位置之后,其间隔相差 offset 指定的字节数。如:
Stream. Seek(-3, Origin.End); 表示在流末端往前数第3个位置。
Stream. Seek(0, Origin.Begin); 表示在流的开头位置。
Stream. Seek(3, Origin.Current); 表示在流的当前位置往后数第三个位置。
4. Close
关闭当前流并释放与之关联的所有资源(如套接字和文件句柄)。 不直接调用此方法,而应确保流得以正确释放。
此方法调用方法 Dispose ,指定 true 以释放所有资源。注意,在流关闭后尝试操作流可能会引发 ObjectDisposedException;不关闭流可能导致数据被篡改或丢失。
总结
Stream 是所有流的抽象基类。流是字节序列的抽象,例如文件、输入/输出设备、进程中通信管道或 TCP/IP 套接字等。Stream 类及其派生类提供这些不同类型的输入和输出的一般视图(方法/途径),并将程序员与操作系统和基础设备的具体详细信息隔离开来。
流涉及三个基本操作:
- 可以从流中读取。读取是将数据从流传输到数据结构(如字节数组)中。
- 可以写入流。写入是指将数据从数据结构传输到流中。
- 流可以支持查找。查找是指查询和修改流中的当前位置。 查找功能取决于流具有的后备存储的类型。例如,网络流没有当前位置的统一概念,因此通常不支持查找。
【有关 C# 中的反射 Reflection】
【参考文献:反射 (C#) | Microsoft Learn && [整理]C#反射(Reflection)详解 - SamWang - 博客园 (cnblogs.com)】
【注:碍于篇幅在此仅对该内容作简要说明,更多详细内容请参阅 反射 (C#) | Microsoft Learn && C# 反射(Reflection) | 菜鸟教程 (runoob.com)】
一、反射的基本概念
用ILDasm工具浏览一个dll和exe的构成,这种机制称为反射。这是 .Net 中获取运行时类型信息的方式,它用于在运行时通过编程方式获得类型信息。反射可以获取已加载的程序集和在其中定义的类型(如类、接口和值类型)信息。也可以使用反射在运行时创建类型实例,以及调用和访问这些实例。反射的一个主要功能就是查找程序集的信息。
二、运行时信息的作用
举个例子来说明,很多开发者喜欢在自己的软件中留下一些接口,其他人可以编写一些插件来扩充软件的功能,比如有一个媒体播放器,我希望以后可以很方便的扩展识别的格式,那么我声明一个接口。这个接口中包含一个Extension属性,这个属性返回支持的扩展名,另一个方法返回一个解码器的对象(这里假设了一个 Decoder 的类,这个类提供把文件流解码的功能,扩展插件可以派生之),通过解码器对象我就可以解释文件流。
那么规定所有的解码插件都必须派生一个解码器,并且实现这个接口,在GetDecoder方法中返回解码器对象,并且将其类型的名称配置到我的配置文件里面。
这样的话,我就不需要在开发播放器的时侯知道将来扩展的格式的类型,只需要从配置文件中获取现在所有解码器的类型名称,而动态的创建媒体格式的对象,将其转换为 IMediaFormat接口来使用。
三、优缺点
优点:
1. 反射提高了程序的灵活性和扩展性。
2. 降低耦合性,提高自适应能力。
3. 它允许程序创建和控制任何类的对象,无需提前硬编码目标类。
缺点:
1. 性能问题:使用反射基本上是一种解释操作,用于字段和方法接入时要远慢于直接代码。因此反射机制主要应用在对灵活性和拓展性要求很高的系统框架上,普通程序不建议使用。
2. 使用反射会模糊程序内部逻辑;程序员希望在源代码中看到程序的逻辑,反射却绕过了源代码的技术,因而会带来维护的问题,反射代码比相应的直接代码更复杂。