В современных сервисах без кэша никуда: доступ к данным в персистентной базе – дело долгое и затратное, поэтому добавление промежуточного хранилища для наиболее часто используемых данных значительно его ускоряет. Держать в кэше информацию можно самую разную и в разной форме: и строки, и списки, и состояние сессии, и многое другое. В данной статье речь пойдёт об одном из способов хранении в кэше «плоских» объектов, не имеющих вложенных классов и циклических ссылок.

Кастомизированная сериализация плоских объектов


Такие кэши как 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)


  1. linuxover
    18.05.2018 15:31

    а что за модель велосипеда на фото? хочу такой же :)


    1. planarik
      18.05.2018 15:38

      Гугл говорит Sada Collapsible Bike, и, похоже, он в единственном экземпляре.


  1. maslyaev
    18.05.2018 19:23
    -1

    Компактный сериализатор для кэша
    Сначала прочитал «Компактный стерилизатор для кошек».
    Доктор, что со мной?


    1. prickly_u Автор
      18.05.2018 21:06

      Кошки они такие. Могут везде начать мерещиться...


  1. Oxoron
    18.05.2018 20:12
    +1

    Для бенчмарков: https://github.com/dotnet/BenchmarkDotNet.
    Для еще большего ускорения можно поменять свойства на поля.
    Спасибо за статью, отличная работа.


    1. prickly_u Автор
      18.05.2018 21:03
      +1

      Благодарю!


  1. ZOXEXIVO
    18.05.2018 20:52
    +1

    Обычно, для таких случаев хватает MsgPack


    1. prickly_u Автор
      18.05.2018 21:04

      Не приходилось раньше слышать. Посмотрю внимательнее, спасибо.


      1. ornic
        19.05.2018 12:31

        И ещё в сторону ProtoBuf от Гугла можно. Структуры он компактно описывает, но гзипом поверх него все равно бывает полезно пройтись.


        Опять же, есть в реализации для всего, можно с фронтом данными обмениваться.


        1. prickly_u Автор
          19.05.2018 14:13

          Да, тоже посмотрю. (Удивительно, что даже для c# есть). Но любой «общий» формат сериализации предусматривает запись хоть каких-то, но метаданных. Тут идея была, что при аккуратном обращении со стабильным форматом классов можно попробовать обойтись вовсе без них.


  1. A_HREF
    18.05.2018 21:11
    +1

    Сравните с Nfx.Slim


    1. prickly_u Автор
      19.05.2018 14:19

      Возможно, попробую позже. Сейчас среда слегка разломана, и есть более актуальные задачи. Насколько я понимаю, Nfx всё-таки под более общие и сложные задачи проектировался, вариант в статье ориентирован на быструю сериализацию, но очень простых объектов.


  1. eugenebb
    18.05.2018 21:28
    +1

    В заголовке можно добавить bit-mask для всех полей, для определения если значение отличается от того что выдает default(T). Типа если true, то пропускаем. Поможет сохранить место и время для классов с большим количеством пустых значений.

    При сериализации больших данных ( > 1Кб), с заведомо хорошо сжимаемыми данными, можно еще gzip прикрутить. При пересылке по сети или сохранении на диск, будет выигрыш по производительности.


  1. eugenebb
    19.05.2018 08:07
    +2

    По мотивам этой статьи, для примера, сделал T4 генератор сериализаторов. Т.е. тоже самое, но вместо IL кода, генерируем C#код и используем его в своём проекте. Скорость работы и размер данных такой-же, но некоторые отличия:

    1. Во время исполнения, не тратим время на генерацию IL кода.

    2. Можем гарантировать порядок сохранения свойств между перезапусками (т.е. при генерации используем сортировку по имени свойства и т.п.)
    .
    3. При наличии типов не поддерживаемых нашим сериализатором в классах с которыми мы работаем, узнаем об этом во время компиляции проекта, а не во время исполнения.

    4. Можем генерировать код для сериализации/десериализации объектов для любого другого языка (например для javascript)

    5. При отладки можно пройти через код в дебагере

    6. Код генератора сильно проще и вносить изменения/кастомизировать легче.


    1. d-stream
      19.05.2018 14:13

      Было бы интересно прочитать в виде статьи или хотя бы глянуть на образчик


    1. prickly_u Автор
      19.05.2018 14:30

      Мне бы тоже было интересно глянуть на код примера.
      Но в целом да, такой подход может быть даже удобнее, когда исходники сериализуемых классов доступны и находятся в том же солюшене. Если в другом/разделяются с другими командами — уже могут быть трудности. Опять же, может всё-таки возникнуть потребность создать десериализатор для старого варианта объекта, который только в виде сборки сохранился. Всё впрочем, зависит от задачи, конечно, и как работа в целом построена.
      T4 у нас использовался для сериализации в другом месте, не знаю, почему он в первую очередь в голову не пришёл.


      1. eugenebb
        19.05.2018 18:31

        К сожалению код остался на работе, но как доберусь скину. Там ~90% вашего кода, остальное обвязка, так что много интересного не ожидайте.

        Как раз проблем со старыми/чужими классами нету, вся информация берётся из DLL. Можно было бы реализовать и через исходники, но подумал что если исходников нету, то придётся делать всё равно получать через DLL, поэтому всё сделал единообразно.


  1. HackerDelphi
    19.05.2018 16:10

    Ещё один хороший вариант — вместо Reflection.Emit использовать System.Lync.Expression.Compile()


    1. Oxoron
      19.05.2018 21:46

      ЕМНИП он в два раза медленнее, при сравнимых затратах на инициализацию.