使用紧凑的序列化器,数倍提升性能 —— ESFramework 4.0 快速上手(11)
在分布式通信系统中,网络传递的是二进制流,而内存中是我们基于对象模型构建的各种各样的对象,当我们需要将一个对象通过网络传递给另一个节点时,首先需要将其序列化为字节流,然后通过网络发送给目标节点,目标节点接收后,再反序列化为对象实例。在ESFramework体系中,也是遵循同样的规则。
ESFramework称这些需要经过网络传递的对象称之为协议类(Contract),协议类通常只是一个简单的数据结构封装,用于保存状态的一个哑类(不包含任何方法,从object继承的除外),有点类似于与数据库中表进行映射的贫血Entity。(关于Contract更详细的介绍可以参见ESFramework 4.0 进阶(01)-- 消息)。基于ESFramework的分布式系统使用这些协议类实例进行数据交换。如果是自己组装骨架流程并使用ESPlus提供的IContractHelper实现,那么ESFramework底层会自动帮我们完成协议对象的序列化和反序列化。
但是,如果我们使用的是Rapid引擎了,我们会经常使用ESPlus.Application.CustomizeInfo.Passive.ICustomizeInfoOutter接口的Send等方法发送二进制自定义信息(实际上,其底层转换为Message、再转换为字节流时,还是由ESFramework自动完成的),这个二进制信息通常是系统中的某个业务对象的序列化结果,而这个序列化过程我们必须自己完成。
一.序列化方式的两种选择
为了将业务对象转换为二进制流,大家通常有两种方案可以选择:使用.NET自带的二进制序列化器,或,先将业务对象转换为字符串(比如xml),再将结果用类似UTF8进行编码得到字节流。 这两种方案都有缺陷。
1..NET自带的二进制序列化器
使用.NET自带的二进制序列化器对我们来说是最方便的,因为只要直接调用API就好了,不需要我们自己动手做任何动作。但是,缺陷也很明显:
(1)首先,能够序列化的类必须加上Serializable标签,且.NET自带的二进制序列化器需要绑定被序列化的类的命名空间和程序集。比方说,将windows里序列化一个协议对象得到的结果传到Silverlight中,是无法完成反序列化的,即使Silverlight中也有完全相同的协议类定义。这也说明,如果通信的客户端是用Silverlight开发的,肯定是不能使用.NET自带的二进制序列化器的。
(2)序列化结果臃肿,其size巨大。在巨大并发高性能的分布式通信系统中,这将降低通信消息,并浪费大量的带宽,是不可忍受的。
(3)效率低下。.NET自带的二进制序列化器基于反射(Relection)的机制工作,比如读取类型的信息、读取/设置字段的值等都是通过反射来完成的。如果每秒仅仅序列化百千个对象,可能还可以应付;但是如果每秒需要序列化几万、甚至几十万个对象,就不堪重负了。
(4)加密困难。由于.NET自带的二进制序列化器在序列化协议对象时,会反射读取对象的内部成员(private),而如果定义协议的dll经过加密后,private成员的名称通常会被混淆成随机的名称,这样就要求通信的各方都使用同一个加密的dll,否则,协议对象的反序列化可能会失败。
2.通过string进行中转
为了解决类似Silverlight客户端与服务端通信的序列化统一的问题,我们可以使用string作为中转,而无论是否为Silverlight环境,对同一字符串进行相同格式(如UTF8)的编码,得到的字节流肯定是一样的。反过来,同样的字节流在不同的环境中使用相同的解码器进行解码得到的字符串也一样。这是一种可行的方案,但是缺点也是有的:
(1)我们需要自己打造协议对象与字符串之间的相互转换 -- 这可能是一个费时费神的又容易出错的工作。
(2)序列化之后的结果的Size取决于我们协议对象与字符串之间相互转换时所采用的规则,同样也取决于我们的耐心程度 -- 为了使结果的Size更小,我们也许要动更多的脑筋。
当然,现在也有现成的将对象与字符串想换转换的工具,比如JSON和XML。就一般而言,使用JSON转换协议对象得到的结果比使用xml要小很多。
(3)同.NET自带的二进制序列化器,对象属性值的读取/设置、对象的创建等通常也是基于反射的,所以,效率同样存在问题。
二.第三种方案
ESPlus提供了更紧凑、且不需绑定命名空间和程序集的二进制序列化器:ESPlus.Serialization.CompactPropertySerializer。
(1)CompactPropertySerializer 基于Emit技术和缓存技术构建,且避免了反射带来的开销,所以效率大大提升。
(2)CompactPropertySerializer 内部使用了简练、紧凑的序列化格式和规则,使得序列化的结果Size更短小。
(3)将被序列化的类不需要增加任何标签,且不依赖于命名空间和程序集,只要类的定义完全一致,CompactPropertySerializer 就可以正常工作。真正的弱侵入性。
(4)ESFramework.SL也提供了完全一样的CompactPropertySerializer ,所以基于第3点,服务端与Silverlight客户端就可以协同工作了。
(5)只要采用CompactPropertySerializer的序列化格式,.NET服务端可以与任何其它语言(如C++、JAVA等)构建的客户端协同工作。
CompactPropertySerializer解决了前面提出的几个问题,当然,它也不是全能的,使用它也有一些限制,下面我们即将讲到。
三.如何使用CompactPropertySerializer
ESPlus.Serialization.CompactPropertySerializer 和 ESFramework.SL.Serialization.CompactPropertySerializer 是ESFramework提供的分别用于桌面应用和Sivlerlight客户端的二进制序列化器。它们的实现几乎一模一样,所以,使用时要注意的方面也是相同的:
(1)CompactPropertySerializer 支持类和结构的序列化,但是被序列化的类或结构必须有默认的构造函数(Ctor)。
(2)CompactPropertySerializer 只序列化那些可读写的属性,如果一个属性仅仅是只读或只写的,那么该属性不会被序列化。这也是CompactPropertySerializer名称中Property的含义。
(3)CompactPropertySerializer 支持的类型:基础数据类型(如int、long、bool等)、string、byte[],以及由这些类型构成的class或struct。
(4)支持多层嵌套 -- 即被序列化的class中可以包含别的类型的对象,只要每一个被嵌入的对象最后都是由基础数据类型构成的。
(5)除byte[]/List<>/Dictionary<,>泛型外,不支持其它的集合类型。
(6)不支持循环引用。如果存在循环引用,序列化时将导致死循环。
正如本文开始提到的,在通信系统中用到的协议类都是一些最简单的仅仅包含数据的“哑类”,所以,上面的限制对我们在设计协议类时是没有什么约束的。尽可能地使用简单的数据类型,然后将需要序列化的字段通过可读写的属性暴露就OK了。
CompactPropertySerializer 包括了序列化和反序列化的两个基本方法:
T Deserialize<T>(byte[] buff, int startIndex) where T : new();
方法含义很明显,不解释了。另外,CompactPropertySerializer 采用了Singleton,我们可以在程序中直接使用这个Singleton ,通过CompactPropertySerializer.Default属性获得该Singleton实例的引用。
四.试试CompactPropertySerializer的性能和效率
我们以文件传送中要使用到的协议类BeginSendFileContract为例,BeginSendFileContract定义如下:
{
#region OriginFilePath
private string originFilePath;
/// <summary>
/// 发送方发送的文件的全路径。
/// </summary>
public string OriginFilePath
{
get { return originFilePath; }
set { originFilePath = value; }
}
#endregion
#region FileLength
private long fileLength = 0;
public long FileLength
{
get
{
return this.fileLength ;
}
set
{
this.fileLength = value ;
}
}
#endregion
#region LastUpdateTime
private DateTime lastUpdateTime;
/// <summary>
/// 被发送的文件最后一次更新时间。
/// </summary>
public DateTime LastUpdateTime
{
get { return lastUpdateTime; }
set { lastUpdateTime = value; }
}
#endregion
#region FileID
private string fileID = "" ;
public string FileID
{
get
{
return this.fileID ;
}
set
{
this.fileID = value ;
}
}
#endregion
#region Comment
private string comment = null;
/// <summary>
/// Comment 用于存储与本次文件发送相关的额外附加信息。比如,在类似ftp的上传文件的服务中,Comment的内容可以是服务器保存上传文件的路径。
/// </summary>
public string Comment
{
get { return comment; }
set { comment = value; }
}
#endregion
}
之所以加上[Serializable]标签,是因为下面测试.NET自带的二进制序列化器需要用到,正式的BeginSendFileContract定义是没有这个标签的。
正如大多数的协议类一样,这个类仅仅包含几个简单类型的属性,现在我们来对比一下.NET自带的二进制序列化器与CompactPropertySerializer的表现。
(1)比较序列化结果的大小:
byte[] result1 = CompactPropertySerializer.Default.Serialize<BeginSendFileContract>(contract);
byte[] result2 = ESBasic.Helpers.SerializeHelper.SerializeObject(contract);
ESBasic.Helpers.SerializeHelper就是对.NET自带的二进制序列化器的简化封装。执行的结果如下:
result1的长度为:32
result2的长度为:242
.Net自带序列化器的结果是CompactPropertySerializer结果的7-8倍,如果为contract的一些string类型的字段赋有意义的值,这个倍数会稍微降一点;如果这个contract的定义包含了更多的要序列化的属性,那么这个倍数还会继续提高。不管怎么样,这个比例都是很吓人的,所以在高频的通信系统中,相比于使用.Net自带序列化器,采用CompactPropertySerializer可以节省大量的带宽。
(2)测试性能:
我们分别运行两个序列化100万次,看所要的时间:
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 1000000; i++)
{
byte[] result1 = CompactPropertySerializer.Default.Serialize<BeginSendFileContract>(contract);
}
stopwatch.Stop();
double span1 = stopwatch.ElapsedMilliseconds;
stopwatch.Reset();
stopwatch.Start();
for (int i = 0; i < 1000000; i++)
{
byte[] result2 = ESBasic.Helpers.SerializeHelper.SerializeObject(contract);
}
stopwatch.Stop();
double span2 = stopwatch.ElapsedMilliseconds;
运行结果如下:
span1:5324ms
span2:17249ms
CompactPropertySerializer比.Net自带的序列化器快3倍以上,优势不言而喻。
大家可以参考上面的demo,写更多的测试程序,来测试更多的内容,包括它们在反序列化方面的表现的比较。
对于一般的通信应用,使用.Net自带的二进制序列化器也许就够用了,不会有太大的影响,但是如果在类似MMORPG、视频/音频会议等等需要高频、高性能的通信系统中,.Net自带的二进制序列化器就不是最好的选择了。如果使用ESFramework来构建你的分布式通信应用,那就可以从CompactPropertySerializer得到更多的帮助。