[.NET]使用十年股價對比各種序列化技術
1. 前言
上一家公司有搞股票,當時很任性地直接從伺服器讀取一個股票10年份的股價(還有各種指標)在客戶端的圖表上顯示,而且因為是桌面客戶端,傳輸的資料也是簡單粗暴地使用Soap序列化。獲取報價的介面大概如下,通過symbol、beginDate和endDate三個引數獲取股票某個時間段的股價:
public IEnumerable<StockPrice> LoadStockPrices(string symbol,DateTime beginDate,DateTime endDate)
{
//some code
}
後來用Xamarin.Forms做了移動客戶端,在手機上就不敢這麼任性了,移動端不僅對流量比較敏感,而且顯示這麼多資料也不現實,於是限制為不可以獲取這麼長時間的股價,選擇一種新的序列化方式也被提上了日程。不過當時我也快離職了所以沒關心這件事。
上週看到這篇問文章:
2. 資料結構
[Serializable] [ProtoContract] [DataContract] public class StockPrice { [ProtoMember(1)] [DataMember] public double ClosePrice { get; set; } [ProtoMember(2)] [DataMember] public DateTime Date { get; set; } [ProtoMember(3)] [DataMember] public double HighPrice { get; set; } [ProtoMember(4)] [DataMember] public double LowPrice { get; set; } [ProtoMember(5)] [DataMember] public double OpenPrice { get; set; } [ProtoMember(6)] [DataMember] public double PrvClosePrice { get; set; } [ProtoMember(7)] [DataMember] public string Symbol { get; set; } [ProtoMember(8)] [DataMember] public double Turnover { get; set; } [ProtoMember(9)] [DataMember] public double Volume { get; set; } }
上面是股價的資料結構,包含股票代號、日期、OHLC、前收市價(PreClosePice),成交額(Turnover)和成交量(Volume),這裡我已經把序列化要用到的Attribute加上了。
測試資料使用長和(00001)2003年開始10年的股價,共2717條資料。為了方便測試已經把它們從資料庫匯出到文字文件。其實大小也就200K而已。
3. 各種序列化技術
在.NET中要執行序列化有很多可以考慮的東西,如網路傳輸、安全性、.NET Remoting的遠端物件等內容。但這裡單純只考慮序列化本身。
3.1 二進位制序列化
二進位制序列化將物件的公共欄位和私有欄位以及類(包括含有該類的程式集)的名稱都轉換成位元組流,對該物件進行反序列化時,將建立原始物件的準確克隆。除了.NET可序列化的型別,其它型別要想序列化,最簡單的方法是使用 SerializableAttribute 對其進行標記。
.NET中使用BinaryFormatter實現二進位制序列化,程式碼如下:
public override byte[] Serialize(List<StockPrice> instance)
{
using (var stream = new MemoryStream())
{
IFormatter formatter = new BinaryFormatter();
formatter.Serialize(stream, instance);
return stream.ToArray();
}
}
public override List<StockPrice> Deserialize(byte[] source)
{
using (var stream = new MemoryStream(source))
{
IFormatter formatter = new BinaryFormatter();
var target = formatter.Deserialize(stream);
return target as List<StockPrice>;
}
}
結果:
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
BinarySerializer | 117 | 12 | 242,460 |
3.2 XML
XML序列化將物件的公共欄位和屬性或者方法的引數及返回值轉換(序列化)為符合特定 XML架構定義語言 (XSD) 文件的 XML 流。由於 XML 是開放式的標準,因此可以根據需要由任何應用程式處理 XML流,而與平臺無關。
.NET中執行Xml序列化可以使用XmlSerializer:
public override byte[] Serialize(List<StockPrice> instance)
{
using (var stream = new MemoryStream())
{
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(List<StockPrice>));
serializer.Serialize(stream, instance);
return stream.ToArray();
}
}
public override List<StockPrice> Deserialize(byte[] source)
{
using (var stream = new MemoryStream(source))
{
var serializer = new System.Xml.Serialization.XmlSerializer(typeof(List<StockPrice>));
var target = serializer.Deserialize(stream);
return target as List<StockPrice>;
}
}
結果如下,因為XML格式為了有較好的可讀性引入了一些冗餘的文字資訊,所以體積膨脹了不少:
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
XmlSerializer | 133 | 26 | 922,900 |
3.3 SOAP
XML 序列化還可用於將物件序列化為符合 SOAP 規範的 XML 流。 SOAP 是一種基於 XML 的協議,它是專門為使用 XML 來傳輸過程呼叫而設計的,熟悉WCF的應該不會對SOAP感到陌生。
.NET中使用SoapFormatter實現序列化,程式碼如下:
public override byte[] Serialize(List<StockPrice> instance)
{
using (var stream = new MemoryStream())
{
IFormatter formatter = new SoapFormatter();
formatter.Serialize(stream, instance.ToArray());
return stream.ToArray();
}
}
public override List<StockPrice> Deserialize(byte[] source)
{
using (var stream = new MemoryStream(source))
{
IFormatter formatter = new SoapFormatter();
var target = formatter.Deserialize(stream);
return (target as StockPrice[]).ToList();
}
}
結果如下,由於它本身的特性,體積膨脹得更可怕了(我記得WCF預設就是使用SOAP?):
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
SoapSerializer | 105 | 123 | 2,858,416 |
3.4 JSON
JSON(JavaScript Object Notation)是一種由道格拉斯·克羅克福特構想和設計、輕量級的資料交換語言,該語言以易於讓人閱讀的文字為基礎,用來傳輸由屬性值或者序列性的值組成的資料物件。
雖然.NET提供了DataContractJsonSerializer,但Json.NET更受歡迎,程式碼如下:
public override byte[] Serialize(List<StockPrice> instance)
{
using (var stream = new MemoryStream())
{
var serializer = new DataContractJsonSerializer(typeof(List<StockPrice>));
serializer.WriteObject(stream, instance);
return stream.ToArray();
}
}
public override List<StockPrice> Deserialize(byte[] source)
{
using (var stream = new MemoryStream(source))
{
var serializer = new DataContractJsonSerializer(typeof(List<StockPrice>));
var target = serializer.ReadObject(stream);
return target as List<StockPrice>;
}
}
結果如下,JSON的體積比XML小很多:
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
JsonSerializer | 40 | 60 | 504,320 |
3.5 Protobuf
其實一開始我和我的同事就清楚用Protobuf最好。
Protocol Buffers 是 Google提供的資料序列化機制。它效能高,壓縮效率好,但是為了提高效能,Protobuf採用了二進位制格式進行編碼,導致可讀性較差。
使用protobuf-net需要將序列化的物件使用ProtoContractAttribute和ProtoMemberAttribute進行標記。序列化和反序列化程式碼如下:
public override byte[] Serialize(List<StockPrice> instance)
{
using (var stream = new MemoryStream())
{
Serializer.Serialize(stream, instance);
return stream.ToArray();
}
}
public override List<StockPrice> Deserialize(byte[] source)
{
using (var stream = new MemoryStream(source))
{
return Serializer.Deserialize<List<StockPrice>>(stream);
}
}
結果十分優秀:
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
ProtobufSerializer | 93 | 18 | 211,926 |
3.6 結果對比
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
BinarySerializer | 117 | 12 | 242,460 |
XmlSerializer | 133 | 26 | 922,900 |
SoapSerializer | 105 | 123 | 2,858,416 |
JsonSerializer | 40 | 60 | 504,320 |
ProtobufSerializer | 93 | 18 | 211,926 |
將上述方案的結果列出來對比,Protobuf序列化後體積最少。不過即使是Protobuf,壓縮後的資料仍然比文字文件的200K還大,那還不如直接傳輸這個文字文件。
4. 優化資料結構
其實傳輸的資料結構上有很大的優化空間。
首先是股票代號Symbol,前面提到獲取股價的介面大概是這樣:IEnumerable
。既然都知道要獲取的股票代號,StockPrice中Symbol這個屬性完全就是多餘的。
其次是OHLC和PreClosePrice,港股(不記得其它Market是不是這樣)的報價肯定是4位有效數字(如95.05和102.4),用float精度也夠了,不必用 double。
最後是Date,反正只需要知道日期,不必知道時分秒,直接用與1970-01-01相差的天數作為儲存應該就可以了。
private static DateTime _beginDate = new DateTime(1970, 1, 1);
public DateTime Date
{
get => _beginDate.AddDays(DaysFrom1970);
set => DaysFrom1970 = (short) Math.Floor((value - _beginDate).TotalDays);
}
[ProtoMember(2)]
[DataMember]
public short DaysFrom1970 { get; set; }
不要以為Volume可以改為int,有些仙股有時會有幾十億的成交量,超過int的最大值2147483647(順便一提Int32的最大值是2的31次方減1,有時面試會考)。
這樣修改後的類結構如下:
[Serializable]
[ProtoContract]
[DataContract]
public class StockPriceSlim
{
[ProtoMember(1)]
[DataMember]
public float ClosePrice { get; set; }
private static DateTime _beginDate = new DateTime(1970, 1, 1);
public DateTime Date
{
get => _beginDate.AddDays(DaysFrom1970);
set => DaysFrom1970 = (short) Math.Floor((value - _beginDate).TotalDays);
}
[ProtoMember(2)]
[DataMember]
public short DaysFrom1970 { get; set; }
[ProtoMember(3)]
[DataMember]
public float HighPrice { get; set; }
[ProtoMember(4)]
[DataMember]
public float LowPrice { get; set; }
[ProtoMember(5)]
[DataMember]
public float OpenPrice { get; set; }
[ProtoMember(6)]
[DataMember]
public float PrvClosePrice { get; set; }
[ProtoMember(8)]
[DataMember]
public double Turnover { get; set; }
[ProtoMember(9)]
[DataMember]
public double Volume { get; set; }
}
序列化的體積大幅減少:
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
BinarySerializer | 11 | 12 | 141,930 |
XmlSerializer | 42 | 24 | 977,248 |
SoapSerializer | 48 | 89 | 2,586,720 |
JsonSerializer | 17 | 33 | 411,942 |
ProtobufSerializer | 7 | 3 | 130,416 |
其實之所以有這麼大的優化空間,一來是因為傳輸的物件本身就是ORM生成的物件沒針對網路傳輸做優化,二來各個券商的資料來源差不多都是這樣傳輸資料的,最後,本來這個介面是給桌面客戶端用的根本就懶得考慮傳輸資料的大小。
5. 自定義的序列化
由於股票的資料結構相對穩定,而且這個介面不需要通用性,可以自己實現序列化。StockPriceSlim所有屬性加起來是38個位元組,測試資料是2717條報價,共103246位元組,少於Protobuf的130416位元組。要達到每個報價只儲存38個位元組,只需將每個屬性的值填入固定的位置:
public override byte[] SerializeSlim(List<StockPriceSlim> instance)
{
var list = new List<byte>();
foreach (var item in instance)
{
var bytes = BitConverter.GetBytes(item.DaysFrom1970);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.OpenPrice);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.HighPrice);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.LowPrice);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.ClosePrice);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.PrvClosePrice);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.Volume);
list.AddRange(bytes);
bytes = BitConverter.GetBytes(item.Turnover);
list.AddRange(bytes);
}
return list.ToArray();
}
public override List<StockPriceSlim> DeserializeSlim(byte[] source)
{
var result = new List<StockPriceSlim>();
var index = 0;
using (var stream = new MemoryStream(source))
{
while (index < source.Length)
{
var price = new StockPriceSlim();
var bytes = new byte[sizeof(short)];
stream.Read(bytes, 0, sizeof(short));
var days = BitConverter.ToInt16(bytes, 0);
price.DaysFrom1970 = days;
index += bytes.Length;
bytes = new byte[sizeof(float)];
stream.Read(bytes, 0, sizeof(float));
var value = BitConverter.ToSingle(bytes, 0);
price.OpenPrice = value;
index += bytes.Length;
stream.Read(bytes, 0, sizeof(float));
value = BitConverter.ToSingle(bytes, 0);
price.HighPrice = value;
index += bytes.Length;
stream.Read(bytes, 0, sizeof(float));
value = BitConverter.ToSingle(bytes, 0);
price.LowPrice = value;
index += bytes.Length;
stream.Read(bytes, 0, sizeof(float));
value = BitConverter.ToSingle(bytes, 0);
price.ClosePrice = value;
index += bytes.Length;
stream.Read(bytes, 0, sizeof(float));
value = BitConverter.ToSingle(bytes, 0);
price.PrvClosePrice = value;
index += bytes.Length;
bytes = new byte[sizeof(double)];
stream.Read(bytes, 0, sizeof(double));
var volume = BitConverter.ToDouble(bytes, 0);
price.Volume = volume;
index += bytes.Length;
bytes = new byte[sizeof(double)];
stream.Read(bytes, 0, sizeof(double));
var turnover = BitConverter.ToDouble(bytes, 0);
price.Turnover = turnover;
index += bytes.Length;
result.Add(price);
}
return result;
}
}
結果如下:
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
CustomSerializer | 5 | 1 | 103,246 |
這種方式不僅序列化後的體積最小,而且序列化和反序列化的速度都十分優秀,不過程式碼十分難看而且沒有擴充套件性。嘗試用反射改進一下:
public override byte[] SerializeSlim(List<StockPriceSlim> instance)
{
var result = new List<byte>();
foreach (var item in instance)
foreach (var property in typeof(StockPriceSlim).GetProperties())
{
if (property.GetCustomAttribute(typeof(DataMemberAttribute)) == null)
continue;
var value = property.GetValue(item);
byte[] bytes = null;
if (property.PropertyType == typeof(int))
bytes = BitConverter.GetBytes((int)value);
else if (property.PropertyType == typeof(short))
bytes = BitConverter.GetBytes((short)value);
else if (property.PropertyType == typeof(float))
bytes = BitConverter.GetBytes((float)value);
else if (property.PropertyType == typeof(double))
bytes = BitConverter.GetBytes((double)value);
result.AddRange(bytes);
}
return result.ToArray();
}
public override List<StockPriceSlim> DeserializeSlim(byte[] source)
{
using (var stream = new MemoryStream(source))
{
var result = new List<StockPriceSlim>();
var index = 0;
while (index < source.Length)
{
var price = new StockPriceSlim();
foreach (var property in typeof(StockPriceSlim).GetProperties())
{
if (property.GetCustomAttribute(typeof(DataMemberAttribute)) == null)
continue;
byte[] bytes = null;
object value = null;
if (property.PropertyType == typeof(int))
{
bytes = new byte[sizeof(int)];
stream.Read(bytes, 0, bytes.Length);
value = BitConverter.ToInt32(bytes, 0);
}
else if (property.PropertyType == typeof(short))
{
bytes = new byte[sizeof(short)];
stream.Read(bytes, 0, bytes.Length);
value = BitConverter.ToInt16(bytes, 0);
}
else if (property.PropertyType == typeof(float))
{
bytes = new byte[sizeof(float)];
stream.Read(bytes, 0, bytes.Length);
value = BitConverter.ToSingle(bytes, 0);
}
else if (property.PropertyType == typeof(double))
{
bytes = new byte[sizeof(double)];
stream.Read(bytes, 0, bytes.Length);
value = BitConverter.ToDouble(bytes, 0);
}
property.SetValue(price, value);
index += bytes.Length;
}
result.Add(price);
}
return result;
}
}
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
ReflectionSerializer | 413 | 431 | 103,246 |
好像好了一些,但效能大幅下降。我好像記得有人說過.NET會將反射快取讓我不必擔心反射帶來的效能問題,看來我的理解有出入。索性自己快取些反射結果:
private readonly IEnumerable<PropertyInfo> _properties;
public ExtendReflectionSerializer()
{
_properties = typeof(StockPriceSlim).GetProperties().Where(p => p.GetCustomAttribute(typeof(DataMemberAttribute)) != null).ToList();
}
Name | Serialize(ms) | Deserialize(ms) | Bytes |
---|---|---|---|
ExtendReflectionSerializer | 11 | 11 | 103,246 |
這樣改進後效能還可以接受。
6. 最後試試壓縮
最後試試在序列化的基礎上再隨便壓縮一下:
public byte[] SerializeWithZip(List<StockPriceSlim> instance)
{
var bytes = SerializeSlim(instance);
using (var memoryStream = new MemoryStream())
{
using (var deflateStream = new DeflateStream(memoryStream, CompressionLevel.Fastest))
{
deflateStream.Write(bytes, 0, bytes.Length);
}
return memoryStream.ToArray();
}
}
public List<StockPriceSlim> DeserializeWithZip(byte[] source)
{
using (var originalFileStream = new MemoryStream(source))
{
using (var memoryStream = new MemoryStream())
{
using (var decompressionStream = new DeflateStream(originalFileStream, CompressionMode.Decompress))
{
decompressionStream.CopyTo(memoryStream);
}
var bytes = memoryStream.ToArray();
return DeserializeSlim(bytes);
}
}
}
結果看來不錯:
Name | Serialize(ms) | Deserialize(ms) | Bytes | Serialize With Zip(ms) | Deserialize With Zip(ms) | Bytes With Zip |
---|---|---|---|---|---|---|
BinarySerializer | 11 | 12 | 141,930 | 22 | 12 | 72,954 |
XmlSerializer | 42 | 24 | 977,248 | 24 | 28 | 108,839 |
SoapSerializer | 48 | 89 | 2,586,720 | 61 | 87 | 140,391 |
JsonSerializer | 17 | 33 | 411,942 | 24 | 35 | 90,125 |
ProtobufSerializer | 7 | 3 | 130,416 | 7 | 6 | 65,644 |
CustomSerializer | 5 | 1 | 103,246 | 9 | 3 | 57,697 |
ReflectionSerializer | 413 | 431 | 103,246 | 401 | 376 | 59,285 |
ExtendReflectionSerializer | 11 | 11 | 103,246 | 13 | 14 | 59,285 |
7. 結語
滿足了好奇心,順便複習了一下各種序列化的方式。
因為原來的需求就很單一,沒有測試各種資料量下的對比。
雖然Protobuf十分優秀,但在本地儲存序列化檔案時為了可讀性我通常都會選擇XML或JSON。
8. 參考
二進位制序列化
XML 和 SOAP 序列化
Json.NET
Protocol Buffers - Google's data interchange format