В современных сервисах без кэша никуда: доступ к данным в персистентной базе – дело долгое и затратное, поэтому добавление промежуточного хранилища для наиболее часто используемых данных значительно его ускоряет. Держать в кэше информацию можно самую разную и в разной форме: и строки, и списки, и состояние сессии, и многое другое. В данной статье речь пойдёт об одном из способов хранении в кэше «плоских» объектов, не имеющих вложенных классов и циклических ссылок.
Кастомизированная сериализация плоских объектов
Такие кэши как Redis Cache для Microsoft Azure не предлагают встроенной сериализации объектов, по крайней мере в составе клиентской библиотеки StackExchange.Redis. Кэш предоставляет методы, позволяющие сохранить под заданным ключом произвольную последовательность байт, а выбор способа их получения из сохраняемого объекта остаётся за пользователем. Одним из возможных вариантов является стандартный .Net-овский BinaryFormatter, однако он не единственный, и выбор удачного способа сериализации для сервиса, хранящего в кэше много объектов, может положительно сказаться на его производительности.
Подход к сериализации, описываемый в данной статье, исходит из следующих предположений о системе:
- Кэш ограничен по объёму и стоит дороже персистентного хранилища. В Microsoft Azure за кэш с большим доступным лимитом ожидаемо приходится платить большую сумму.
- Большое количество байт в сериализованном представлении негативно сказывается на времени взаимодействия кэшем, не только из-за затрат на запись/считывание, но также из-за передачи данных по сети.
- Состав сериализуемых объектов стабилен во времени и находится под полным контролем владельца сервиса и кэша. Неожиданного появления, удаления или переупорядочивания полей не происходит, как это может быть с сериализованными данными, пришедшими извне.
- Кэш — всего лишь средство оптимизации и временное хранилище объектов. При расхождении формата или проблемах десериализации удаление старого объекта из кэша и замена новым не будет представлять собой большую проблему
На фоне этого привлекательной идеей кажется экономия изрядного количества байт за счёт исключения из сериализованного представления метаданных о полях, классах и сборках, сохраняемых всеми штатными сериализаторами, включая BinaryFormatter. В конце концов, если владелец всё знает о составе полей, что мешает, например, просто сформатировать их в строчку в определённом порядке, разделив каким-либо заведомо не встречающимся в их составе символом?
"7c9e6679-7425-40de-944b-e07fc1f90ae7|48972|Alice in Wonderland"
Хотя такой подход и требует некоторой аккуратности, пагубным его не назовёшь.
Если пойти ещё дальше, то можно обойтись и без разделителя, и без форматирования в строку: любой тип представляется в памяти последовательностью байт, и, зная тип того или иного свойства, можно просто поочерёдно записывать и считывать массивы байт нужной длины, конвертируя их из/в значения нужных типов. Для типов с нефиксированной длинной, таких как string или Array, можно предварять сериализованное значение количеством байт/элементов, которые в нём содержатся. Со свойствами, имеющими тип пользовательского класса или структуры дело обстоит сложнее, особенно если нужно отслеживать циклические ссылки и в данном простейшем случае они пока не рассматриваются. (Это, однако, не означает, что подобное нереализуемо в принципе).
При хранении в кэше большого количества объектов с большим количеством полей написание кода их сериализации по описанному принципу может быть утомительным и чреватым ошибками. Если объекты нужно сохранять целиком, то подобная операция легко автоматизируется средствами пространства имён System.Reflection:
public override void Serialize(TObject theObject, Stream stream)
{
foreach (var property in _properties)
{
var val = property.GetValue(theObject);
if (property.PropertyType == typeof(byte))
{
stream.WriteByte((byte)val);
}
else if (property.PropertyType == typeof(bool))
{
var bytes = BitConverter.GetBytes((bool)val);
stream.Write(bytes, 0, bytes.Length);
}
else if (property.PropertyType == typeof(int))
{
var bytes = BitConverter.GetBytes((int)val);
stream.Write(bytes, 0, bytes.Length);
}
else if (property.PropertyType == typeof(Guid))
{
var bytes = ((Guid)val).ToByteArray();
stream.Write(bytes, 0, bytes.Length);
}
...
}
}
public override TObject Deserialize(Stream stream)
{
var theObject = Activator.CreateInstance<TObject>();
foreach (var property in _properties)
{
object val;
if (property.PropertyType == typeof(byte))
{
val = stream.ReadByte();
}
var bytesCount = TypesInfo.GetBytesCount(type);
var valueBytes = new byte[bytesCount];
stream.Read(valueBytes, 0, valueBytes.Length);
if (property.PropertyType == typeof(bool))
{
val = BitConverter.ToBoolean(valueBytes, 0);
}
else if (property.PropertyType == typeof(int))
{
val = BitConverter.ToInt32(valueBytes, 0);
}
else if (property.PropertyType == typeof(Guid))
{
val = new Guid(valueBytes);
}
...
property.SetValue(theObject, val);
}
}
Состав свойств объекта и его изменения
Несмотря на то, что MSDN не гарантирует возврат свойств методом Type.GetProperties() в алфавитном порядке или порядке декларирования, нет причин полагать, что от вызова к вызову на одной и той же версии одного и того же типа объекта возвращаемый массив будет отличаться. Для большей надёжности можно один раз вызвать данный метод, сохранить полученный массив свойств в приватном поле и использовать его далее и в операциях сериализации, и десериализации. Кэш обычно используется системами, выполняющимися без остановки длительное время, и единожды созданный таким образом сериализатор с единожды проинициализированным списком свойств сериализуемого типа будет существовать долго. В случае, если и это кажется недостаточным, можно было бы дополнительно реализовать сохранение этого списка на диск с переинициализацией при рестарте сервиса, но это выглядит излишний предосторожностью.
Тем не менее, при изменении состава свойств, их названия, типа или взаимного расположения корректно десериализовать старый вариант уже не представляется возможным. Однако, поскольку описываемая идея предназначена для сохранения данных в кэше, а не постоянной базе данных, наиболее простой способ справится с возникшим расхождением — это инвалидировать старое байтовое представление и заменить на заново сериализованный объект. Возможны и более сложные подходы, например, при помощи отдельного AppDomain-а загрузить старый вариант типа, десериализовать объект, заполнить его свойствами новый объект, сериализовать и сохранить его под тем же ключом; в рамках данной статьи, однако, попыток выполнить подобное не предпринималось.
В любом случае, для отслеживания изменений в типе объекта, процесс сериализации должен включать возможность версионирования. Например, перед байтами самого объекта может быть записана версия сборки, в которой он объявлен:
public virtual string GetTypeVersion()
{
return typeof(TObject).Assembly.GetName().Version.ToString();
}
var reflectionSerializer = new ReflectionCompactSerializer<Entity>();
typeVersion = reflectionSerializer.GetTypeVersion();
reflectionSerializer.WriteVersion(stream, typeVersion);
reflectionSerializer.Serialize(originalEntity, stream);
var version = reflectionSerializer.ReadObjectVersion(stream);
deserializedEntity = reflectionSerializer.Deserialize(stream);
При сериализации множества разных объектов, для каждого из которых метод GetProperties() вызывается только один раз с сохранением результата в памяти, необходимо как-то сопоставлять типы объектов с полученными списками свойств. Для этого можно либо использовать словарь Type > PropertyInfo[], либо выделять специализированные сериализаторы для каждого сериализуемого типа при помощи Generics. Второй подход субъективно выглядит более удобным:
public class ReflectionCompactSerializer<TObject> : CompactSerializerBase<TObject>
where TObject: class, new ()
{
private readonly PropertyInfo[] _properties =
typeof(TObject).GetProperties(BindingFlags.Instance | BindingFlags.Public);
...
}
Более производительный подход
Следующая проблема, возникающая при использовании Reflection в разрезе данной задачи — это опять же производительность: отражение никогда не считалось быстрым механизмом. В таких книгах по оптимизации .Net-приложений как Sasha Goldshtein, Dima Zurbalev, Ido Flatow «Pro .Net Performance: Optimize Your C# Applications» и Ben Watson «Writing High-Performance .NET Code» в качестве одной из методик оптимизации при работе с Reflection и создании пользовательских сериализаторов предлагается генерация кода, например при помощи средств пространства имен System.Reflection.Emit. Идея при таком подходе состоит в том, чтобы по полученному списку свойств создавать сразу код, т. е. последовательность инструкций, которая будет поочерёдно получать значение каждого из свойств, писать его в поток байт, считывать, преобразовывать, устанавливать значение и т. п.
Класс ILGenerator из пространства имён System.Reflection.Emit содержит ряд методов, позволяющих создавать инструкции промежуточного языка MSIL, которые после этого могут быть скомпилированы в run-time при помощи класса DynamicMethod. В общих чертах это выглядит так:
public static EmitSerializer<TObject> Generate<TObject>()
where TObject : class, new()
{
var propertiesWriter = new DynamicMethod(
"WriteProperties",
null,
new Type[] { typeof(Stream), typeof(TObject) },
typeof(EmitSerializer<TObject>));
var writerIlGenerator = propertiesWriter.GetILGenerator();
var writerEmitter = new CodeEmitter(writerIlGenerator);
var propertiesReader = new DynamicMethod(
"ReadProperties",
null,
new Type[] { typeof(Stream), typeof(TObject) },
typeof(EmitSerializer<TObject>));
var readerIlGenerator = propertiesReader.GetILGenerator();
var readerEmitter = new CodeEmitter(readerIlGenerator);
var properties = typeof(TObject)
.GetProperties(BindingFlags.Instance | BindingFlags.Public);
foreach(var property in properties)
{
if (property.PropertyType == typeof(byte))
{
writerEmitter.EmitWriteBytePropertyCode(property);
readerEmitter.EmitReadBytePropertyCode(property);
}
else if (property.PropertyType == typeof(Guid))
{
writerEmitter.EmitWriteGuidPropertyCode(property);
readerEmitter.EmitReadGuidPropertyCode(property);
}
…
}
var writePropertiesDelegate =
(Action<Stream,TObject>)propertiesWriter
.CreateDelegate(typeof(Action<Stream, TObject>));
var readPropertiesDelegate =
(Action<Stream, TObject>)propertiesReader
.CreateDelegate(typeof(Action<Stream, TObject>));
return new EmitSerializer<TObject>(
writePropertiesDelegate, readPropertiesDelegate);
}
}
Разумеется, за пределами «общих черт», наиболее сложная и интересная часть заключается в реализации методов EmitWriteNNNPropertyCode/EmitReadNNNPropertyCode.
MSIL — это «высокоуровневый ассемблер» и код на нём порой читать сложно, не то что писать, особенно опосредованным способом через вызов методов ILGenerator.Emit(OpCode).
Тут помогает уловка, приведённая в одной из вышеупомянутых книг: не обязательно писать код на IL целиком «с нуля». Вполне можно создать «заготовку» на C#, создать с ней сборку, дизассемблировать в IL и, глядя на полученную эталонную реализацию, обобщить её в соответствии со своими нуждами.
Дизассемблеров, позволяющих под Windows получить из .Net-сборки IL-код существует большое количество: ildasm, dotPeek, ILSpy и т. д. Так получилось, однако, что данный проект, начатый под ОС от Microsoft, дописывался уже под Linux (благо .NET Core позволяет), где выбор дизассемблеров не так велик. Тем не менее, инструменты имеются и под эту операционную систему, в частности monodis. Получить текстовый файл и исходным кодом на IL из dll-сборки при помощи monodis можно следующей командой:
monodis <путь к сборке> --output=<путь к выходному файлу>
Обобщённая сериализация простейших типов
Все действия, выполняемые генерируемым сериализатором, аналогичны операциям изначального Reflection-сериализатора и повторить их через Emit проще, чем может показаться на первый взгляд. Например, «заготовка», получающая значение свойства типа int и записывающая его байты в поток, может выглядеть так:
private static void WritePrimitiveTypeProperty(Stream stream, Entity entity)
{
var index = entity.Index;
var valueBytes = BitConverter.GetBytes(index);
stream.Write(valueBytes, 0, valueBytes.Length);
}
После сборки и декомпиляции, соответствующий IL-код будет содержать следующие инструкции:
.method private static hidebysig
default void WritePrimitiveTypeProperty (class [mscorlib]System.IO.Stream 'stream', class SourcesForIL.Entity entity) cil managed
{
// Method begins at RVA 0x241c
// Code size 28 (0x1c)
.maxstack 4
.locals init (
int32 V_0,
unsigned int8[] V_1)
IL_0000: nop
IL_0001: ldarg.1
IL_0002: callvirt instance int32 class SourcesForIL.Entity::get_Index()
IL_0007: stloc.0
IL_0008: ldloc.0
IL_0009: call unsigned int8[] class [mscorlib]System.BitConverter::GetBytes(int32)
IL_000e: stloc.1
IL_000f: ldarg.0
IL_0010: ldloc.1
IL_0011: ldc.i4.0
IL_0012: ldloc.1
IL_0013: ldlen
IL_0014: conv.i4
IL_0015: callvirt instance void class [mscorlib]System.IO.Stream::Write(unsigned int8[], int32, int32)
IL_001a: nop
IL_001b: ret
}
А код, повторяющий его средствами пространства имён Reflection.Emit, вызывает такую последовательность методов ILGenerator-а:
var byteArray = _ilGenerator.DeclareLocal(typeof(byte[]));
// load object under serialization onto the evaluation stack
_ilGenerator.Emit(OpCodes.Ldarg_1);
// get property value
_ilGenerator.EmitCall(OpCodes.Callvirt, property.GetMethod, null);
// get value's representation in bytes
_ilGenerator.EmitCall(OpCodes.Call, BitConverterMethodsInfo.ChooseGetBytesOverloadByType(valueType), null);
// save the bytes array from the stack in local variable
_ilGenerator.Emit(OpCodes.Stloc, byteArray);
// load stream parameter onto the evaluation stack
_ilGenerator.Emit(OpCodes.Ldarg_0);
// load bytesCount array
_ilGenerator.Emit(OpCodes.Ldloc_S, bytesArray);
// load offset parameter == 0 onto the stack
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// load bytesCount array
_ilGenerator.Emit(OpCodes.Ldloc_S, bytesArray);
// calculate the array length
_ilGenerator.Emit(OpCodes.Ldlen);
// convert it to Int32
_ilGenerator.Emit(OpCodes.Conv_I4);
// write array to stream
_ilGenerator.EmitCall(OpCodes.Callvirt, StreamMethodsInfo.Write, null);
Следует обратить внимание, что получение значения свойства происходит единообразно для всех типов – при помощи property.GetMethod. Точно также легко обобщается преобразование этого значения в массив байт: нужно лишь использовать подходящий аргумент типа MethodInfo. Таким образом, одна и та же функция генератора может создавать код, сериализующий свойства разных типов, в зависимости от переданного ей метода получения массива байт.
Класс System.BitConverter содержит несколько перегруженных методов GetBytes, для нескольких строенных типов, и для всех этих типов операция сериализации может быть произведена единообразно, за счёт выбора нужного варианта MethodInfo в BitConverterMethodsInfo.ChooseGetBytesOverloadByType(valueType).
Получать объект MedodInfo для соответствующих методов приходится снова через Reflection и для более быстрого к ним доступа имеет смысл сохранять их в статическом словаре:
public static MethodInfo ChooseGetBytesOverloadByType(Type type)
{
if (_getBytesMethods.ContainsKey(type))
{
return _getBytesMethods[type];
}
var method = typeof(BitConverter).GetMethod("GetBytes", new Type[] { type });
if (method == null)
{
throw new InvalidOperationException("No overload for parameter of type "
+ type.Name);
}
_getBytesMethods[type] = method;
return method;
}
Вышеприведённый код позволяет генерировать код для нескольких системных типов: bool, short, int, long, ushort, uint, ulong, double, float, char.
Сериализация decimal, guid и byte
Из списка примитивных типов выбивается тип decimal, для которого в System.BitConverter нет встроенного метода получения массива байт. Поэтому методы преобразования приходится реализовывать самостоятельно:
public static byte[] GetDecimalBytes(decimal value)
{
var bits = decimal.GetBits((decimal)value);
var bytes = new List<byte>();
foreach (var bitsPart in bits)
{
bytes.AddRange(BitConverter.GetBytes(bitsPart));
}
return bytes.ToArray();
}
public static decimal BytesToDecimal(byte[] bytes, int startIndex)
{
var valueBytes = bytes.Skip(startIndex).ToArray();
if (valueBytes.Length != 16)
throw new Exception("A decimal must be created from exactly 16 bytes");
var bits = new Int32[4];
for (var bitsPart = 0; bitsPart <= 15; bitsPart += 4)
{
bits[bitsPart/4] = BitConverter.ToInt32(valueBytes, bitsPart);
}
return new decimal(bits);
}
Для типа Guid перегрузка метода BitConverter.GetBytes так же отсутствует, но получение его байтового представления тривиально — при помощи метода Guid.ToByteArray. Для восстановления значения из массива байт у Guid есть конструктор.
private static void WriteGuidProperty(Stream stream, Entity entity)
{
var id = entity.Id;
var valueBytes = id.ToByteArray();
stream.Write(valueBytes, 0, valueBytes.Length);
}
private static void ReadGuidProperty(Stream stream, Entity entity)
{
var valueBytes = new byte[16];
stream.Read(valueBytes, 0, valueBytes.Length);
entity.Id = new Guid(valueBytes);
}
С byte дела обстоят совсем просто: класс Stream имеет специальные методы записи-чтения единичного байта.
Сериализация даты и времени
Чуть сложнее дело обстоит с типами DateTime и DateTimeOffset, поскольку они, определяются не только значением времени, но ещё и полями Kind и Offset соответственно; эти поля нужно записать/считать наряду с самим временем. Генерация IL-кода, сохраняющего значение переменной DateTimeOffset, например, выглядит так:
private void EmitWriteDateTimeOffsetVariable(LocalBuilder dateTimeOffset)
{
var offset = _ilGenerator.DeclareLocal(typeof(TimeSpan));
var dateTimeTicks = _ilGenerator.DeclareLocal(typeof(long));
var dateTimeTicksByteArray = _ilGenerator.DeclareLocal(typeof(byte[]));
var offsetTicksByteArray = _ilGenerator.DeclareLocal(typeof(byte[]));
// load the variable address to the stack
_ilGenerator.Emit(OpCodes.Ldloca_S, dateTimeOffset);
// call method to get Offset property
_ilGenerator.EmitCall(OpCodes.Call, DateTimeOffsetMembersInfo.OffsetProperty, null);
// save it to local variable
_ilGenerator.Emit(OpCodes.Stloc, offset);
// load the variable address to the stack
_ilGenerator.Emit(OpCodes.Ldloca_S, offset);
// call method to get offset Ticks property
_ilGenerator.EmitCall(OpCodes.Call, TimeSpanMembersInfo.TicksProperty, null);
// convert it to byte array
_ilGenerator.EmitCall(OpCodes.Call, GetInt64BytesMethodInfo, null);
// save it to local variable
_ilGenerator.Emit(OpCodes.Stloc, offsetTicksByteArray);
EmitWriteBytesArrayToStream(offsetTicksByteArray);
// load the dateTimeOffset variable address to the stack
_ilGenerator.Emit(OpCodes.Ldloca_S, dateTimeOffset);
// call method to get Ticks property
_ilGenerator.EmitCall(OpCodes.Call, DateTimeOffsetMembersInfo.TicksProperty, null);
// save it to local variable
_ilGenerator.Emit(OpCodes.Stloc, dateTimeTicks);
// load the variable address to the stack
_ilGenerator.Emit(OpCodes.Ldloc, dateTimeTicks);
// convert it to byte array
_ilGenerator.EmitCall(OpCodes.Call, GetInt64BytesMethodInfo, null);
// save it to local variable
_ilGenerator.Emit(OpCodes.Stloc, dateTimeTicksByteArray);
EmitWriteBytesArrayToStream(dateTimeTicksByteArray);
}
Определение длины типа в байтах
Десериализация всех вышеперечисленных типов симметрична, однако для считывания соответствующего массива байт, необходимо знать его длину. Во время выполнения тип свойства доступен как значение класса Type, и применить к нему операцию sizeof не получится. На помощь приходит метод Marshal.SizeOf, который, однако, применим не ко всем типам, и уж тем более не возвращает количество байт, записываемых кастомными реализациями сохранения. Для них, однако, можно просто явно возвращать размер:
public static int GetBytesCount(Type propertyType)
{
if (propertyType == typeof(DateTime))
{
return sizeof(long) + 1;
}
else if (propertyType == typeof(DateTimeOffset))
{
return sizeof(long) + sizeof(long);
}
else if (propertyType == typeof(bool))
{
return sizeof(bool);
}
else if(propertyType == typeof(char))
{
return sizeof(char);
}
else if (propertyType == typeof(decimal))
{
return 16;
}
else
{
return Marshal.SizeOf(propertyType);
}
}
Сериализация Nullable<T>
При сериализации Nullable<> логично записывать вначале флаг, определяющий, содержит ли свойство null, и только если значение не пусто, записывать само значение, при помощи уже реализованных методов.
public void EmitWriteNullablePropertyCode(PropertyInfo property)
{
var nullableValue = _ilGenerator.DeclareLocal(property.PropertyType);
var isNull = _ilGenerator.DeclareLocal(typeof(bool));
var isNullByte = _ilGenerator.DeclareLocal(typeof(byte));
var underlyingType = property.PropertyType.GetGenericArguments().Single();
var value = _ilGenerator.DeclareLocal(underlyingType);
var valueBytes = _ilGenerator.DeclareLocal(typeof(byte[]));
var nullableInfo = NullableInfo.GetNullableInfo(underlyingType);
var nullFlagBranch = _ilGenerator.DefineLabel();
var byteFlagLabel = _ilGenerator.DefineLabel();
var noValueLabel = _ilGenerator.DefineLabel();
EmitLoadPropertyValueToStack(property);
// save nullable value to local variable
_ilGenerator.Emit(OpCodes.Stloc, nullableValue);
// load address of the variable to stack
_ilGenerator.Emit(OpCodes.Ldloca_S, nullableValue);
// get HasValue property
_ilGenerator.EmitCall(OpCodes.Call, nullableInfo.HasValueProperty, null);
// load value '0' to stack
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// compare
_ilGenerator.Emit(OpCodes.Ceq);
// save to local boolean variable
_ilGenerator.Emit(OpCodes.Stloc, isNull);
// load to stack
_ilGenerator.Emit(OpCodes.Ldloc, isNull);
// jump to isNull branch, if needed
_ilGenerator.Emit(OpCodes.Brtrue_S, nullFlagBranch);
// load value '0' to stack
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// jump to byteFlagLabel
_ilGenerator.Emit(OpCodes.Br_S, byteFlagLabel);
_ilGenerator.MarkLabel(nullFlagBranch);
// load value '1' to stack
_ilGenerator.Emit(OpCodes.Ldc_I4_1);
_ilGenerator.MarkLabel(byteFlagLabel);
// convert to byte
_ilGenerator.Emit(OpCodes.Conv_U1);
// save to local variable
_ilGenerator.Emit(OpCodes.Stloc, isNullByte);
// load stream parameter to stack
_ilGenerator.Emit(OpCodes.Ldarg_0);
// load byte flag to the stack
_ilGenerator.Emit(OpCodes.Ldloc, isNullByte);
// write it to the stream
_ilGenerator.EmitCall(OpCodes.Callvirt, StreamMethodsInfo.WriteByte, null);
// load isNull flag to stack
_ilGenerator.Emit(OpCodes.Ldloc, isNull);
// load value '0'
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// compare
_ilGenerator.Emit(OpCodes.Ceq);
// jump to tne end, if no value presented
_ilGenerator.Emit(OpCodes.Brfalse_S, noValueLabel);
// load the address of the nullable to the stack
_ilGenerator.Emit(OpCodes.Ldloca_S, nullableValue);
// get actual value
_ilGenerator.EmitCall(OpCodes.Call, nullableInfo.ValueProperty, null);
EmitWriteValueFromStackToStream(underlyingType);
_ilGenerator.MarkLabel(noValueLabel);
}
Обработка типа string
Значения типа string не имеют фиксированного размера и количество хранимых в них байт заранее неизвестно. Тем не менее, длину конкретной строки можно сохранить в поток перед записью составляющих её байт. При десериализации же можно вначале считать массив байт, содержащих int с длиной строки, получить значение этой длины и далее читать уже соответствующее ей количество байт. В случае null-строки, можно записать значение «-1», чтобы уметь отличать её от пустой строки длинной 0.
Преобразование строки в массив байт/из него легко выполняется посредством методов Encoding.GetBytes/Encoding.GetString. Для разнообразия приведён метод чтения строки из потока, а не записи:
private void EmitReadStringFromStreamToStack()
{
var bytesCoutArray = _ilGenerator.DeclareLocal(typeof(byte[]));
var stringBytesCount = _ilGenerator.DeclareLocal(typeof(int));
var stringBytesArray = _ilGenerator.DeclareLocal(typeof(byte[]));
var isNull = _ilGenerator.DeclareLocal(typeof(bool));
var isNotNullBranch = _ilGenerator.DefineLabel();
var endOfReadLabel = _ilGenerator.DefineLabel();
var propertyBytesCount = TypesInfo.GetBytesCount(typeof(int));
// push the amout of bytes to read onto the stack
_ilGenerator.Emit(OpCodes.Ldc_I4, propertyBytesCount);
// allocate array to store bytes
_ilGenerator.Emit(OpCodes.Newarr, typeof(byte));
// stores the allocated array in the local variable
_ilGenerator.Emit(OpCodes.Stloc, bytesCoutArray);
// push the stream parameter
_ilGenerator.Emit(OpCodes.Ldarg_0);
// push the byte count array
_ilGenerator.Emit(OpCodes.Ldloc, bytesCoutArray);
// push '0' as the offset parameter
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// push the byte array again - to calculate its length
_ilGenerator.Emit(OpCodes.Ldloc, bytesCoutArray);
// get the length
_ilGenerator.Emit(OpCodes.Ldlen);
// convert the result to Int32
_ilGenerator.Emit(OpCodes.Conv_I4);
// call the stream.Read method
_ilGenerator.EmitCall(OpCodes.Callvirt, StreamMethodsInfo.Read, null);
// pop amount of bytes read
_ilGenerator.Emit(OpCodes.Pop);
// push the bytes count array
_ilGenerator.Emit(OpCodes.Ldloc, bytesCoutArray);
// push '0' as the start index parameter
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// convert the bytes to Int32
_ilGenerator.EmitCall(OpCodes.Call, BytesToInt32MethodInfo, null);
// save bytes count to local variable
_ilGenerator.Emit(OpCodes.Stloc, stringBytesCount);
// load it to the stack
_ilGenerator.Emit(OpCodes.Ldloc, stringBytesCount);
// put value '-1' to the stack
_ilGenerator.Emit(OpCodes.Ldc_I4_M1);
// compare bytes count and -1
_ilGenerator.Emit(OpCodes.Ceq);
// save to boolean variable
_ilGenerator.Emit(OpCodes.Stloc, isNull);
// load to stack
_ilGenerator.Emit(OpCodes.Ldloc, isNull);
// if false, jump to isNotNullBranch
_ilGenerator.Emit(OpCodes.Brfalse_S, isNotNullBranch);
// push 'null' value
_ilGenerator.Emit(OpCodes.Ldnull);
// jump to the end of read fragment
_ilGenerator.Emit(OpCodes.Br_S, endOfReadLabel);
// not null string value branch
_ilGenerator.MarkLabel(isNotNullBranch);
// load bytes count to the stack
_ilGenerator.Emit(OpCodes.Ldloc, stringBytesCount);
// allocate array to store bytes
_ilGenerator.Emit(OpCodes.Newarr, typeof(byte));
// save it to local variable
_ilGenerator.Emit(OpCodes.Stloc, stringBytesArray);
// push the stream parameter
_ilGenerator.Emit(OpCodes.Ldarg_0);
// load string bytes array to stack
_ilGenerator.Emit(OpCodes.Ldloc, stringBytesArray);
// push '0' as the start index parameter
_ilGenerator.Emit(OpCodes.Ldc_I4_0);
// load string bytes array to stack to get array length
_ilGenerator.Emit(OpCodes.Ldloc, stringBytesArray);
// get the length
_ilGenerator.Emit(OpCodes.Ldlen);
// convert the result to Int32
_ilGenerator.Emit(OpCodes.Conv_I4);
// call the stream.Read method
_ilGenerator.EmitCall(OpCodes.Callvirt, StreamMethodsInfo.Read, null);
// pop amount of bytes read
_ilGenerator.Emit(OpCodes.Pop);
// load Encoding to stack
_ilGenerator.EmitCall(OpCodes.Call, EncodingMembersInfo.EncodingGetter, null);
// load string bytes
_ilGenerator.Emit(OpCodes.Ldloc, stringBytesArray);
// call Encoding.GetString() method
_ilGenerator.EmitCall(OpCodes.Callvirt, EncodingMembersInfo.GetStringMethod, null);
_ilGenerator.MarkLabel(endOfReadLabel);
}
Работа с массивами и коллекциями
Подходом, аналогичным работе со строками можно воспользоваться при сериализации массива: вначале записывается (считывается) его длина, а затем в цикле с соответствующим количеством итераций, поочерёдно записывается (считывается) каждый элемент, при помощи одного из реализованных выше способов обработки отдельного значения.
public void EmitReadArrayPropertyCode(PropertyInfo property)
{
var elementType = property.PropertyType.GetElementType();
var elementBytesArray = _ilGenerator.DeclareLocal(typeof(byte[]));
var lengthBytes = _ilGenerator.DeclareLocal(typeof(byte[]));
var arrayLength = _ilGenerator.DeclareLocal(typeof(int));
var array = _ilGenerator.DeclareLocal(property.PropertyType);
var element = _ilGenerator.DeclareLocal(elementType);
var index = _ilGenerator.DeclareLocal(typeof(int));
var isNullArrayLabel = _ilGenerator.DefineLabel();
var setPropertyLabel = _ilGenerator.DefineLabel();
var loopConditionLabel = _ilGenerator.DefineLabel();
var loopIterationLabel = _ilGenerator.DefineLabel();
// push deserialized object to stack
_ilGenerator.Emit(OpCodes.Ldarg_1);
EmitAllocateBytesArrayForType(typeof(int), lengthBytes);
EmitReadByteArrayFromStream(lengthBytes);
EmitConvertBytesArrayToPrimitiveValueOnStack(lengthBytes, typeof(int));
// save it to local variable
_ilGenerator.Emit(OpCodes.Stloc, arrayLength);
EmitJumpIfNoElements(arrayLength, isNullArrayLabel);
// push array length to stack
_ilGenerator.Emit(OpCodes.Ldloc, arrayLength);
// create new array
_ilGenerator.Emit(OpCodes.Newarr, elementType);
// save it to the local variable
_ilGenerator.Emit(OpCodes.Stloc, array);
EmitZeroIndex(index);
if (elementType != typeof(string))
{
EmitAllocateBytesArrayForType(elementType, elementBytesArray);
}
// jump to the loop condition check
_ilGenerator.Emit(OpCodes.Br_S, loopConditionLabel);
_ilGenerator.MarkLabel(loopIterationLabel);
if (elementType == typeof(string))
{
EmitReadStringFromStreamToStack();
}
else
{
EmitReadValueFromStreamToStack(elementType, elementBytesArray);
}
// save to local variable
_ilGenerator.Emit(OpCodes.Stloc, element);
// load array instance to stack
_ilGenerator.Emit(OpCodes.Ldloc, array);
// load element index
_ilGenerator.Emit(OpCodes.Ldloc_S, index);
// load the element to stack
_ilGenerator.Emit(OpCodes.Ldloc_S, element);
// set element to the array
_ilGenerator.Emit(OpCodes.Stelem, elementType);
EmitIndexIncrement(index);
_ilGenerator.MarkLabel(loopConditionLabel);
EmitIndexIsLessCheck(index, arrayLength);
// jump to the iteration if true
_ilGenerator.Emit(OpCodes.Brtrue_S, loopIterationLabel);
// push filled array to stack
_ilGenerator.Emit(OpCodes.Ldloc, array);
// jump to SetProperty label
_ilGenerator.Emit(OpCodes.Br_S, setPropertyLabel);
_ilGenerator.MarkLabel(isNullArrayLabel);
_ilGenerator.Emit(OpCodes.Ldnull);
_ilGenerator.MarkLabel(setPropertyLabel);
// call object's property setter
_ilGenerator.EmitCall(OpCodes.Callvirt, property.SetMethod, null);
}
Помимо массивов, в проекте так же реализованы Generic-коллекции. Тестирование проводилось на списках List<>, но код спроектирован так, чтобы обрабатывалось любое свойство-коллекция, если оно удовлетворяет следующим условиям:
- имеет публичный конструктор без параметров;
- реализует интерфейс ICollection<T>;
- T является простым типом, рассмотренным ранее;
Сериализация и десериализация коллекций реализована сходным с массивами образом, с поправкой на использование методов и свойств Add, Count, GetEnumerator, имеющихся вследствие реализации интерфейса ICollection<>, а так же за счёт вызовов MoveNext и Current у полученного Enumerator-a.
Тестирование, сравнение, выводы
Данное исследование было бы неполным без сравнения полученных сериализаторов со штатными вариантами. Для оценки выигрыша были выбраны упомянутый BinaryFormatter и Newtonsoft JsonSerializer, как одни из самых популярных библиотечных реализаций. Xml-сериализация не рассматривалась, поскольку она заведомо ещё более «многословна». Сравнение производилось по усреднённому из 1000 попыток времени сериализации/десериализации, а также размеру сериализованного представления в байтах. Объект для эксперимента включал свойства всех вышеупомянутых типов, и не содержал свойств, не поддерживаемых данной реализацией:
var originalEntity = new Entity
{
Name = "Name",
ShortName = string.Empty,
Description = null,
Label = 'L',
Age = 32,
Index = -7,
IsVisible = true,
Price = 225.87M,
Rating = 4.8,
Weigth = 130,
ShortIndex = short.MaxValue,
LongIndex = long.MinValue,
UnsignedIndex = uint.MaxValue,
ShortUnsignedIndex = 25,
LongUnsignedIndex = 11,
Id = Guid.NewGuid(),
CreatedAt = DateTime.Now,
CreatedAtUtc = DateTime.UtcNow,
LastAccessed = DateTime.MinValue,
ChangedAt = DateTimeOffset.Now,
ChangedAtUtc = DateTimeOffset.UtcNow,
References = null,
Weeks = new List<short>() { 3, 12, 24, 48, 53, 61 },
PricesHistory = new decimal[] { 225.8M, 226M, 227.87M, 224.87M },
BitMap = new bool[] { true, true, false, true, false, false, true, true },
ChildrenIds = new Guid [] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() },
Schedule = new DateTime []
{
DateTime.Now.AddDays(-1),
DateTime.Now.AddMonths(2),
DateTime.Now.AddYears(10)
},
Moments = new DateTimeOffset []
{
DateTimeOffset.UtcNow.AddDays(-5),
DateTimeOffset.Now.AddDays(10)
},
Tags = new List<string>
{
"The quick brown fox jumps over the lazy dog",
"Reflection.Emit",
string.Empty,
"0"
},
AlternativeId = Guid.NewGuid()
};
Нужно отметить, что на таком объекте даже «чистый» Reflection-сериализатор показал результаты лучше, чем штатные варианты, очевидно, из-за их ориентированности на более общие и сложные задачи. EmitSerializer имел ещё более высокие результаты (без учёта времени разовой компиляции сгенерированного кода). Значения, полученные в ходе замеров:
Serializer | Average elapsed, ms | Size, bytes
-------------------------------------------------------------------------------
EmitSerializer | 9.9522 | 477
-------------------------------------------------------------------------------
ReflectionSerializer | 22.9454 | 477
-------------------------------------------------------------------------------
BinaryFormatter | 246.4836 | 1959
-------------------------------------------------------------------------------
Newtonsoft JsonSerializer | 87.1893 | 1156
EmitSerializer compiled in: 104.5019 ms
Исходный код
Исходники решения можно найти на Github.
Реализация, однако, предоставляется as is, без гарантий безошибочности и надёжности. С проекта, в рамках которого возникла данная идея, автор на данный момент ушёл, и возможности провести испытания в «боевых условиях» не имел.
Код написан под .NET Core 2.0, собирался и тестировался на ОС Linux Ubuntu 16.04 LTS.
Комментарии (19)
Oxoron
18.05.2018 20:12+1Для бенчмарков: https://github.com/dotnet/BenchmarkDotNet.
Для еще большего ускорения можно поменять свойства на поля.
Спасибо за статью, отличная работа.
ZOXEXIVO
18.05.2018 20:52+1Обычно, для таких случаев хватает MsgPack
prickly_u Автор
18.05.2018 21:04Не приходилось раньше слышать. Посмотрю внимательнее, спасибо.
ornic
19.05.2018 12:31И ещё в сторону ProtoBuf от Гугла можно. Структуры он компактно описывает, но гзипом поверх него все равно бывает полезно пройтись.
Опять же, есть в реализации для всего, можно с фронтом данными обмениваться.
prickly_u Автор
19.05.2018 14:13Да, тоже посмотрю. (Удивительно, что даже для c# есть). Но любой «общий» формат сериализации предусматривает запись хоть каких-то, но метаданных. Тут идея была, что при аккуратном обращении со стабильным форматом классов можно попробовать обойтись вовсе без них.
A_HREF
18.05.2018 21:11+1Сравните с Nfx.Slim
prickly_u Автор
19.05.2018 14:19Возможно, попробую позже. Сейчас среда слегка разломана, и есть более актуальные задачи. Насколько я понимаю, Nfx всё-таки под более общие и сложные задачи проектировался, вариант в статье ориентирован на быструю сериализацию, но очень простых объектов.
eugenebb
18.05.2018 21:28+1В заголовке можно добавить bit-mask для всех полей, для определения если значение отличается от того что выдает default(T). Типа если true, то пропускаем. Поможет сохранить место и время для классов с большим количеством пустых значений.
При сериализации больших данных ( > 1Кб), с заведомо хорошо сжимаемыми данными, можно еще gzip прикрутить. При пересылке по сети или сохранении на диск, будет выигрыш по производительности.
eugenebb
19.05.2018 08:07+2По мотивам этой статьи, для примера, сделал T4 генератор сериализаторов. Т.е. тоже самое, но вместо IL кода, генерируем C#код и используем его в своём проекте. Скорость работы и размер данных такой-же, но некоторые отличия:
1. Во время исполнения, не тратим время на генерацию IL кода.
2. Можем гарантировать порядок сохранения свойств между перезапусками (т.е. при генерации используем сортировку по имени свойства и т.п.)
.
3. При наличии типов не поддерживаемых нашим сериализатором в классах с которыми мы работаем, узнаем об этом во время компиляции проекта, а не во время исполнения.
4. Можем генерировать код для сериализации/десериализации объектов для любого другого языка (например для javascript)
5. При отладки можно пройти через код в дебагере
6. Код генератора сильно проще и вносить изменения/кастомизировать легче.prickly_u Автор
19.05.2018 14:30Мне бы тоже было интересно глянуть на код примера.
Но в целом да, такой подход может быть даже удобнее, когда исходники сериализуемых классов доступны и находятся в том же солюшене. Если в другом/разделяются с другими командами — уже могут быть трудности. Опять же, может всё-таки возникнуть потребность создать десериализатор для старого варианта объекта, который только в виде сборки сохранился. Всё впрочем, зависит от задачи, конечно, и как работа в целом построена.
T4 у нас использовался для сериализации в другом месте, не знаю, почему он в первую очередь в голову не пришёл.eugenebb
19.05.2018 18:31К сожалению код остался на работе, но как доберусь скину. Там ~90% вашего кода, остальное обвязка, так что много интересного не ожидайте.
Как раз проблем со старыми/чужими классами нету, вся информация берётся из DLL. Можно было бы реализовать и через исходники, но подумал что если исходников нету, то придётся делать всё равно получать через DLL, поэтому всё сделал единообразно.
HackerDelphi
19.05.2018 16:10Ещё один хороший вариант — вместо Reflection.Emit использовать System.Lync.Expression.Compile()
linuxover
а что за модель велосипеда на фото? хочу такой же :)
planarik
Гугл говорит Sada Collapsible Bike, и, похоже, он в единственном экземпляре.