Маппінг в C# на прикладі сериализатора для AMF

Вітаю, друзі. Сьогодні мова піде про реалізацію мапінгу на C#, а так само про застосування цього реалізації у вирішенні реальних завдань на прикладі відправки даних AMF на сервер. Все нижчевикладене не претендує на якісь еталони реалізації алгоритмів і патернів проектування коду, це лише опис одного з багатьох, далеко не завжди очевидних для новачків, рішень.

У процесі вивчення статті, Ви дізнаєтеся, як реалізувати власні атрибути і як їх застосовувати, познайомитеся з методами типів розширень і застосуванням рефлексії на практиці, дізнаєтеся про основи HTML в цілому і OpCodes зокрема, а так само про те, як можна сериализации об'єкти в AMF з допомогою потоків.

Постановка завдання
Для початку, давайте складемо приблизний перелік завдань, які необхідно буде вирішувати в даній статті. Нехай він буде виглядати наступним чином:

  • Можливість описати будь-які типи даних сервера в коді C# і зручно працювати з ними;
  • Можливість серіалізації/десеріалізації будь-яких типів (як описаних нами, так і немає);
  • Сам механізм відправки даних на сервер.
В рамках статті ми з Вами будемо працювати з якимсь готовим абстрактним сервером (у вашому випадку — це цілком реальний діючий сервер, реалізоване без вашої участі). Припустимо, нам відомо, що сервер-сайд написаний на Flex, так само ми вже отримали пряме посилання, по якій нам треба відправити AMF-об'єкт. Для роботи безпосередньо з самим AMF у нас є чудовий фреймворк FluorineFX, забезпечує нас всім необхідним інструментарієм для осередку підтримки Flex як на стороні сервера, так і на стороні клієнта. Але от біда, коли справа доходить до того самого довгоочікуваного моменту відправлення об'єкта на сервер, з'ясовується, що в FluorineFX не передбачено зручних засобів для мапінгу, а всі наші відправляються на сервер об'єкти полетять з початковими метаданими типів. По суті це абсолютно не є проблемою, якщо автором клієнт — сервер-сайда є Ви, але як бути в ситуаціях, коли необхідно відправляти пакети даних, що мають кастомний структуру? Повторювати 1 в 1 нэймспэйсы і імена типів не завжди зручно (а часом і зовсім неможливо). Трохи погугливши, знаходимо цілком робоче рішення неподалік. Відразу розглянемо його основні плюси і мінуси:

Плюси:

  • Швидкість. Безпосередньо вносимо правки у вихідний код FluorineFX, збираємо і радіємо.
Мінуси:

  • Підтримка. Якщо Ви працюєте в команді, Вам доведеться подбати про те, щоб исходники FluorineFX завжди були доступні всім учасникам командної розробки і завжди зберігали актуальність. Це тягне за собою більший обсяг проектного коду, а отже і більше часу на його підтримку;

  • Оновлення. Якщо вийде нова версія FluorineFX, Вам доведеться заново оновлювати весь вихідний код і вносити до нього поправки, зберігаючи при цьому максимальну сумісність, що вкрай не зручно. Тут, мабуть, єдині позитивні моменти — далеко не завжди може знадобитися новий функціонал оновлення, а сам фреймворк вкрай рідко оновлюється. Але для оптимального робочого процесу (а так само для випадків, коли схожа ситуація може відбутися при вирішенні іншої задачі) цей момент цілком критичний.
Для тих, кого мінуси даного рішення не бентежать, весь викладений нижче матеріал, можливо, не знадобиться. Для тих же, хто вирішив разом зі мною піти обхідним шляхом і знайти більш гнучке рішення, матеріал, наведений нижче, буде дуже цікавий.

Робота з атрибутами
Загалом, підхід, описаний в рішення йде у вірному напрямку. Маппінг сам по собі передбачає певну прив'язку на рівні метаданих типів, для цього нам знадобляться атрибути.

Давайте спершу складемо приблизний опис того, як наші типи повинні виглядати при підготовці їх до серіалізації в AMF. Передбачимо наступні можливості:

  • Типи, для яких заданий атрибут, сериализуются з іменем, вказаним у властивості атрибута;
  • Типи, для яких не заданий атрибут (або ті атрибути, у яких властивість дорівнює null), сериализуются з початковим ім'ям типу, визначеного в оригінальній збірці;
  • Властивості і поля типів, для яких заданий атрибут, сериализуются з іменем, вказаним у властивості атрибута;
  • Властивості і поля типів, для яких заданий атрибут, але властивість атрибута при цьому дорівнює null, сериализуются з початковим ім'ям типу, визначеного в оригінальній збірці;
  • Властивості і поля типів, для яких не заданий атрибут, в процесі серіалізації не беруть участь і до складу членів кінцевого типу не входять;
  • Словники повинні сериализоваться як асоціативні масиви AMF.
  • Будь-які типи масивів повинні сериализоваться як масиви об'єктів AMF.
  • Built-in-типи повинні сериализоваться як built-in-типи об'єктів AMF.
Почнемо з реалізації класу наших атрибутів. Ми можемо написати єдиний атрибут для типів об'єктів і типів полів і властивостей об'єктів (аналогічно рішення), але я рекомендую реалізувати окремі атрибути для типів і членів типів, що в майбутньому стане в нагоді для випадків, коли необхідно розділяти логіку обробки метаданих у різних типів примірників. Спершу реалізуємо клас атрибута для типів об'єктів:

/// < summary>
/// Становить атрибут серіалізації примірника об'єкта AMF.
/ / / < /summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class AmfObjectAttribute : Attribute
{
/// <summary>
/// Ім'я типу об'єкта.
/ / / < /summary>
public string Name { get; set; }

/// <summary>
/// Ініціалізує новий екземпляр класу <see cref="AmfObjectAttribute"/>.
/ / / < /summary>
/ / / < param name="name">Ім'я типу об'єкта.</param>
public AmfObjectAttribute(string name)
{
Name = name;
}

/// <summary>
/// Ініціалізує новий екземпляр класу <see cref="AmfObjectAttribute"/>.
/ / / < /summary>
public AmfObjectAttribute() : this(null) { }
}

Пара ключових моментів у реалізації атрибутів:

  • Будь атрибут повинен спадкуватися від класу System.Attribute;
  • Атрибут AttributeUsageAttribute необхідний для визначення області застосування атрибута. З його допомогою можна задавати і вилучати типи метаданих, до яких реалізований атрибут буде застосовний;
  • Якщо не задавати конструктор за замовчуванням, можна буде явно присвоювати значення властивостям атрибута при його ініціалізації, тільки через перевантажений конструктор з параметрами;
  • При вказівці типом атрибута, суфікс ...Attribute можна опустити.
Атрибут AmfObjectAttribute ми будемо застосовувати до типів об'єктів. Якщо цей атрибут буде застосований, ім'я типу в сериализованного об'єкта буде збігатися зі значенням, зазначеним у властивості AmfObjectAttribute.Name, якщо значення властивості дорівнює null — у сериализованного об'єкта залишиться оригінальне ім'я типу. Я навмисно не став робити перевірку наявності атрибуту в типів, щоб реалізувати можливість серіалізації об'єктів типів, не позначених даними атрибутом.

Тепер реалізуємо клас атрибута для властивостей і полів у типів:

/// < summary>
/// Становить атрибут для серіалізації полів і властивостей примірника об'єкта AMF.
/ / / < /summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class AmfMemberAttribute : Attribute
{
/// <summary>
/// Ім'я властивості або поля.
/ / / < /summary>
public string Name { get; set; }

/// <summary>
/// Ініціалізує новий екземпляр класу <see cref="AmfMemberAttribute"/>.
/ / / < /summary>
/ / / < param name="name">Ім'я властивості або поля.</param>
public AmfMemberAttribute(string name)
{
Name = name;
}

/// <summary>
/// Ініціалізує новий екземпляр класу <see cref="AmfMemberAttribute"/>.
/ / / < /summary>
public AmfMemberAttribute() : this(null) { }
}

З реалізацією атрибутів розібралися, тепер давайте відразу напишемо простенький клас, що описує деяку модель даних. Надалі ми будемо використовувати його для тестів:

/// < summary>
/// Представляє клас AMF-об'єкта для прикладу. Після серіалізації ім'я типу у об'єкта має стати "namespase.of.your.object".
/ / / < /summary>
[AmfObject("namespase.of.your.object")]
public class CustomAmfObject
{
/// <summary>
/// Тестове властивість типу <see cref="bool"/>. Після серіалізації ім'я властивості має стати "bit_prop".
/ / / < /summary>
[AmfMember("bit_prop")]
public bool BooleanProperty { get; set; } = true;

/// <summary>
/// Тестове властивість типу <see cref="sbyte"/>. Після серіалізації ім'я властивості має залишитися UnsignedByteProperty.
/ / / < /summary>
[AmfMember]
public sbyte UnsignedByteProperty { get; set; } = 2;

/// <summary>
/// Тестове властивість типу <see cref="string"/>. У серіалізації не бере участь.
/ / / < /summary>
public string StringProperty { get; set; } = "test";

/// <summary>
/// Тестове поле типу <see cref="bool"/>. Після серіалізації ім'я поля має стати "bit_fld".
/ / / < /summary>
[AmfMember("bit_fld")]
public bool booleanField = false;

/// <summary>
/// Тестове поле типу <see cref="float"/>. Після серіалізації ім'я властивості має залишитися singleField.
/ / / < /summary>
[AmfMember]
public float singleField = -5.00065 f;

/// <summary>
/// Тестове поле типу <see cref="string"/>. У серіалізації не бере участь.
/ / / < /summary>
public string stringField = "test2";

/// <summary>
/// Ініціалізує новий екземпляр класу <see cref="CustomAmfObject"/>.
/ / / < /summary>
public CustomAmfObject() { }
}

Атрибути написані, тестовий клас реалізований. Тепер нам потрібно приступити до вирішення завдання безпосередньо самої серіалізації. Якщо планується підтримка версії .NET 2.0, то нам необхідно реалізувати клас-сериализатор, який буде працювати з екземплярами об'єктів і проводити з метаданими їх типів різні маніпуляції. Ми ж будемо писати код з урахуванням підтримки версії .NET 3.5, бо в ньому з'явились дві дуже значимі для C#-інженера можливості: LINQ і методи розширень типів. Їх для вирішення нашого завдання ми і застосуємо.

Методи розширень і рефлексія
Щоб реалізувати метод розширення типу, досить оголосити публічний статичний клас і додати до першого параметру модифікатор this. Ці умови є обов'язковими, інакше компілятор просто не зрозуміє, що метод є розширенням типу. Після реалізації, метод можна буде застосувати до будь-якого об'єкта, тип якої співпадає або наледуется від типу першого параметра методу розширення. Створимо наш власний клас методів розширень:

/// < summary>
/// Представляє набір розширень для роботи з серіалізацією/десериализацией об'єктів AMF.
/ / / < /summary>
public static class Extensions { }

Першим ділом, нам знадобиться кілька допоміжних методів для повторного використання коду. В .NET 4.5 з'явився метод Attribute Type.GetCustomAttribute(Type), який дозволяє відразу отримати атрибут заданого типу. В .NET 3.5 такого ще немає, так що реалізуємо кілька методів розширень для зручної роботи з атрибутами:

/// < summary>
/// Знаходить і повертає атрибут.
/ / / < /summary>
/// <typeparam name="T">Тип потрібного атрибуту.</typeparam>
/ / / < param name="sourceType">Вихідний тип для пошуку.</param>
/ / / < returns></returns>
private static T GetAttribute<T>(this Type sourceType) where T : Attribute
{
object[] attributes = sourceType.GetCustomAttributes(typeof(T), true); // Отримуємо поточний атрибут.

if (attributes == null || attributes.Length == 0) return default(T); // Якщо у типу об'єкта не заданий атрибут - повертаємо null.

return attributes[0] as T;
}

/// <summary>
/// Знаходить і повертає атрибут.
/ / / < /summary>
/// <typeparam name="T">Тип потрібного атрибуту.</typeparam>
/ / / < param name="sourceMember">Вихідний тип для пошуку.</param>
/ / / < returns></returns>
private static T GetAttribute<T>(this MemberInfo sourceMember) where T : Attribute
{
object[] attributes = sourceMember.GetCustomAttributes(typeof(T), true); // Отримуємо поточний атрибут.

if (attributes == null || attributes.Length == 0) return default(T); // Якщо у типу об'єкта не заданий атрибут - повертаємо null.

return attributes[0] as T;
}

/// <summary>
/// Визначає, чи заданий зазначений атрибут типу.
/ / / < /summary>
/// <typeparam name="T">Тип атрибута.</typeparam>
/ / / < param name="sourceType">Вихідний тип об'єкта.</param>
/ / / < returns></returns>
private static bool IsDefinedAttribute<T>(this Type sourceType)
{
object[] attributes = sourceType.GetCustomAttributes(typeof(T), true); // Отримуємо поточний атрибут.
return attributes != null && attributes.Length > 0;
}

При кожній процедурі серіалізації буде відбуватися генерація типів за заданими атрибутами. Щоб не повторювати безліч операцій при кожному виклику методу серіалізації, давайте відразу передбачимо можливість зберігання метаданих динамічної збірці після її створення:

/// < summary>
/// Форми для модуля динамічної складання.
/ / / < /summary>
private static ModuleBuilder moduleBuilder;

/// <summary>
/// Статичний конструктор класу <see cref="Extensions"/>.
/ / / < /summary>
static Extensions()
{
AssemblyName assemblyName = new AssemblyName("AmfDynamicAssembly"); // Створюємо нове середовище виконання коду.
AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndSave); // Визначаємо середовище виконання.
moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName.Name, assemblyName.Name + ".dll"); // Визначаємо новий модуль для середовища виконання.
}

Тепер для всіх типів у нас буде власна єдина динамічна збірка, яка буде зберігати метадані. Саме час реалізувати алгоритм генерації типів на основі наших даних атрибутів. Спершу покроково розберемо кожний етап алгоритму, потім я наведу повний лістинг методу.

В основі самого поняття «рефлексії» (воно ж «рефлекшн», воно ж «відображення») і лежать маніпуляції з метаданими типів. Тут, по суті, все просто. Нам потрібно повторити процес створення описаних в коді даних типів компілятором, але вже з допомогою власного коду. За основу братимемо метадані відомого нам типу вихідного об'єкта, а також дані з атрибутів, якщо вони є. Так само нам знадобиться реалізувати конструктор за замовчуванням, ініціалізувалися посилання на екземпляр об'єкта генерованого типу. Всі необхідні нам операції ми можемо здійснити з допомогою класу ModuleBuilder.TypeBuilder.

Визначаємо тип за допомогою TypeBuilder:

TypeBuilder typeBuilder = moduleBuilder.DefineType(typeName, TypeAttributes.Public);

Визначаємо поле за допомогою FieldBuilder:

FieldBuilder fieldBuilder = typeBuilder.DefineField($"m_{propertyName}", propertyType, FieldAttributes.Private);

Визначаємо властивість за допомогою PropertyBuilder. Тут нам потрібно визначити приватне поле, а так само аксессор і мутатор для доступу до нього:

PropertyBuilder propertyBuilder = typeBuilder.DefineProperty(propertyName, PropertyAttributes.HasDefault, propertyType, null); // Визначаємо нову властивість.
MethodAttributes getSetAttr = MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig; // Встановлюємо атрибути аксессору і мутатору властивості.

MethodBuilder methodBuilderAccessor = typeBuilder.DefineMethod($"get_{propertyName}", getSetAttr, propertyType, Type.EmptyTypes); // Визначаємо аксессор.
ILGenerator accessorIL = methodBuilderAccessor.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для аксессора.
accessorIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент. 
accessorIL.Emit(OpCodes.Ldfld, fieldBuilder); // Поміщаємо в стек обчислень інструкцію про отримання значення за посиланням поля.
accessorIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.
MethodBuilder methodBuilderSetter = typeBuilder.DefineMethod($"set_{propertyName}", getSetAttr, null, new Type[] { propertyType }); // Визначаємо мутатор.
ILGenerator setterIL = methodBuilderSetter.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для мутатора.
setterIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент.
setterIL.Emit(OpCodes.Ldarg_1); // Поміщаємо в стек обчислень перший аргумент.
setterIL.Emit(OpCodes.Stfld, fieldBuilder); // Поміщаємо в стек обчислень інструкцію про збереження значення за посиланням поля.
setterIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.

propertyBuilder.SetGetMethod(methodBuilderAccessor); // Додаємо властивості аксессор.
propertyBuilder.SetSetMethod(methodBuilderSetter); // Додаємо властивості мутатор.

Визначаємо конструктор за замовчуванням за допомогою ConstructorBuilder:

ConstructorBuilder ctor = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); // Визначаємо конструктор.
ILGenerator ctorIL = ctor.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для конструктора.
ctorIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент. 
ctorIL.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)); // Викликаємо базовий конструктор для ініціалізації значення за промовчанням нульового аргументу.
ctorIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.

Ініціалізуємо новий екземпляр об'єкта тільки що згенерованого типу за допомогою Activator:

object targetObject = Activator.CreateInstance(typeBuilder.CreateType());

У всіх збирачів метаданих методів є метод (майже каламбур) GetILGenerator(), повертає екземпляр ILGenerator, який дозволяє нам помістити в стек обчислень DOS потрібну послідовність операторів на виконання. Від набору інструкцій IL, які ми передаємо з OpCodes, безпосередньо залежить те, як поведе себе логіка описуваного методу. У цій статті я лише поверхово торкаюся тему рефлексії, це вже привід для окремої статті, крім того Ви завжди зможете ознайомитися з повним переліком інструкцій в офіційній документації MSDN.

Тепер у нас є все необхідне для написання логіки динамічної генерації метаданих типу. Відразу врахуємо той факт, що властивості і поля у генерованого типу так само можуть мати наш атрибут серіалізації, для реалізації застосуємо рекурсію. Якщо об'єкт не заданий атрибут зіставлення імен типів повертаємо його як є. Якщо у динамічній збірці вже є метадані нашого типу — збираємо екземпляр об'єкта на їх основі. Це все корисно для оптимізації. Повний лістинг методу генерації, з усіма необхідними перевірками і процедурами, виглядає наступним чином:

/// < summary>
/// Генерує об'єкт з метаданими типу у відповідності з заданими атрибутами <see cref="AmfObjectAttribute"/>, з полями типу, заданого в атрибутах <see cref="AmfMemberAttribute"/>.
/ / / < /summary>
/ / / < param name="sourceObject">Вихідний екземпляр об'єкта.</param>
/ / / < returns></returns>
private static object GenerateType<T>(T sourceObject)
{
Type sourceType = sourceObject.GetType(); // Отримуємо метадані типу вихідного об'єкта.

if (sourceType.IsDictionary()) return GenerateType(sourceObject as IEnumerable<KeyValuePair<string, object>>);
if (!sourceType.IsDefinedAttribute<AmfObjectAttribute>()) return sourceObject; // Якщо у типу об'єкта не заданий атрибут - повертаємо як є.

string typeName = sourceType.GetAttribute<AmfObjectAttribute>().Name ?? sourceType.FullName; // Визначаємо ім'я типу.
Type definedType = moduleBuilder.GetType(typeName); // Намагаємося знайти вже визначений у збірці тип.
TypeBuilder typeBuilder = null; // Визначаємо білдер для нашого типу.

Словник<string, object> properties = new Dictionary<string, object>(); // Словник властивостей об'єкта.
Словник<string, object> fields = new Dictionary<string, object>(); // Словник полів об'єкта.

// Якщо тип в збірці ще не визначено...
if (definedType == null)
{
typeBuilder = moduleBuilder.DefineType(typeName, TypeAttributes.Public); // Опледеляем тип з нашим ім'ям.

ConstructorBuilder ctor = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); // Визначаємо конструктор.
ILGenerator ctorIL = ctor.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для конструктора.
ctorIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент. 
ctorIL.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)); // Викликаємо базовий конструктор для ініціалізації значення за промовчанням нульового аргументу.
ctorIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.

// Перебираємо всі властивості нашого типу.
foreach (PropertyInfo propertyInfo in sourceType.GetProperties())
{
AmfMemberAttribute attribute = propertyInfo.GetAttribute<AmfMemberAttribute>(); // Отримуємо наш кастомный атрибут типу AmfMemberAttribute.

if (attribute == null) continue; // Якщо атрибут не вказаний - пропускаємо властивість.

string propertyName = attribute.Name ?? propertyInfo.Name; // Отримуємо ім'я властивості.
object propertyValue = propertyInfo.GetValue(sourceObject, null); // Отримуємо значення властивості.
Type propertyType = propertyInfo.PropertyType; // Отримуємо метадані типу властивості.

// Якщо у типу заданий атрибут або це словник...
if (propertyInfo.PropertyType.IsDefinedAttribute<AmfObjectAttribute>() || propertyType.IsDictionary())
{
// Генеруємо об'єкт типу, заданого в атрибуті.
propertyValue = propertyType.IsDictionary()
? GenerateType(propertyValue as IEnumerable<KeyValuePair<string, object>>)
: GenerateType(propertyValue);

propertyType = propertyValue.GetType(); // Оновлюємо тип властивості.
}

FieldBuilder fieldBuilder = typeBuilder.DefineField($"m_{propertyName}", propertyType, FieldAttributes.Private); // Визначаємо нове приватне поле.
PropertyBuilder propertyBuilder = typeBuilder.DefineProperty(propertyName, PropertyAttributes.HasDefault, propertyType, null); // Визначаємо нову властивість.
MethodAttributes getSetAttr = MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig; // Встановлюємо атрибути аксессору і мутатору властивості.

MethodBuilder methodBuilderAccessor = typeBuilder.DefineMethod($"get_{propertyName}", getSetAttr, propertyType, Type.EmptyTypes); // Визначаємо аксессор.
ILGenerator accessorIL = methodBuilderAccessor.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для аксессора.
accessorIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент. 
accessorIL.Emit(OpCodes.Ldfld, fieldBuilder); // Поміщаємо в стек обчислень інструкцію про отримання значення за посиланням поля.
accessorIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.
MethodBuilder methodBuilderSetter = typeBuilder.DefineMethod($"set_{propertyName}", getSetAttr, null, new Type[] { propertyType }); // Визначаємо мутатор.
ILGenerator setterIL = methodBuilderSetter.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для мутатора.
setterIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент.
setterIL.Emit(OpCodes.Ldarg_1); // Поміщаємо в стек обчислень перший аргумент.
setterIL.Emit(OpCodes.Stfld, fieldBuilder); // Поміщаємо в стек обчислень інструкцію про збереження значення за посиланням поля.
setterIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.

propertyBuilder.SetGetMethod(methodBuilderAccessor); // Додаємо властивості аксессор.
propertyBuilder.SetSetMethod(methodBuilderSetter); // Додаємо властивості мутатор.

properties.Add(propertyName, propertyValue); // Зберігаємо значення в словник для подальшої передачі значень властивостям.
}

// Перебираємо всі поля нашого типу.
foreach (FieldInfo fieldInfo in sourceType.GetFields())
{
AmfMemberAttribute attribute = fieldInfo.GetAttribute<AmfMemberAttribute>(); // Отримуємо наш кастомный атрибут типу AmfMemberAttribute.

if (attribute == null) continue; // Якщо атрибут не вказаний - пропускаємо полі.

string fieldName = attribute.Name ?? fieldInfo.Name; // Отримуємо ім'я поля.
object fieldValue = fieldInfo.GetValue(sourceObject); // Отримуємо значення поля.
Type fieldType = fieldInfo.FieldType; // Отримуємо метадані типу поля.

// Якщо у типу заданий атрибут або це словник...
if (fieldInfo.FieldType.IsDefinedAttribute<AmfObjectAttribute>() || fieldType.IsDictionary())
{
// Генеруємо об'єкт типу, заданого в атрибуті.
fieldValue = fieldType.IsDictionary()
? GenerateType(fieldValue as IEnumerable<KeyValuePair<string, object>>)
: GenerateType(fieldValue);

fieldType = fieldValue.GetType(); // Оновлюємо тип поля.
}

typeBuilder.DefineField(fieldName, fieldType, FieldAttributes.Public); // Визначаємо нове поле.
fields.Add(fieldName, fieldValue); // Зберігаємо значення в словник для подальшої передачі значень властивостям.
}
}
else
{
// Перебираємо всі властивості нашого типу.
foreach (PropertyInfo propertyInfo in sourceType.GetProperties())
{
AmfMemberAttribute attribute = propertyInfo.GetAttribute<AmfMemberAttribute>(); // Отримуємо наш кастомный атрибут типу AmfMemberAttribute.

if (attribute == null) continue; // Якщо атрибут не вказаний - пропускаємо властивість.

string propertyName = attribute.Name ?? propertyInfo.Name; // Отримуємо ім'я властивості.
object propertyValue = propertyInfo.GetValue(sourceObject, null); // Отримуємо значення властивості.
Type propertyType = propertyInfo.PropertyType; // Отримуємо метадані типу властивості.

AmfObjectAttribute propertyAttribute = propertyInfo.PropertyType.GetAttribute<AmfObjectAttribute>(); // Отримуємо атрибут у властивості.

// Якщо у типу заданий атрибут або це словник...
if (propertyAttribute != null || propertyType.IsDictionary())
{
// Генеруємо об'єкт типу, заданого в атрибуті.
propertyValue = propertyType.IsDictionary()
? GenerateType(propertyValue as IEnumerable<KeyValuePair<string, object>>)
: GenerateType(propertyValue);

propertyType = propertyValue.GetType(); // Оновлюємо тип властивості.
}

properties.Add(propertyName, propertyValue); // Зберігаємо значення в словник для подальшої передачі значень властивостям.
}

// Перебираємо всі поля нашого типу.
foreach (FieldInfo fieldInfo in sourceType.GetFields())
{
AmfMemberAttribute attribute = fieldInfo.GetAttribute<AmfMemberAttribute>(); // Отримуємо наш кастомный атрибут типу AmfMemberAttribute.

if (attribute == null) continue; // Якщо атрибут не вказаний - пропускаємо полі.

string fieldName = attribute.Name ?? fieldInfo.Name; // Отримуємо ім'я поля.
object fieldValue = fieldInfo.GetValue(sourceObject); // Отримуємо значення поля.
Type fieldType = fieldInfo.FieldType; // Отримуємо метадані типу поля.

AmfObjectAttribute fieldAttribute = fieldInfo.FieldType.GetAttribute<AmfObjectAttribute>(); // Отримуємо атрибут у поля.

// Якщо у типу заданий атрибут або це словник...
if (fieldAttribute != null || fieldType.IsDictionary())
{
// Генеруємо об'єкт типу, заданого в атрибуті.
fieldValue = fieldType.IsDictionary()
? GenerateType(fieldValue as IEnumerable<KeyValuePair<string, object>>)
: GenerateType(fieldValue);

fieldType = fieldValue.GetType(); // Оновлюємо тип поля.
}

fields.Add(fieldName, fieldValue); // Зберігаємо значення в словник для подальшої передачі значень властивостям.
}
}

object targetObject = Activator.CreateInstance(definedType ?? typeBuilder.CreateType()); // Створюємо інстанси нашого динамічного типу.

// Раставляем значення всіх властивостей об'єкта.
foreach (KeyValuePair<string, object> property in properties) targetObject.GetType().GetProperty(property.Key).SetValue(targetObject, property.Value, null);

// Раставляем значення всіх полів об'єкта.
foreach (KeyValuePair<string, object> field in fields) targetObject.GetType().GetField(field.Key).SetValue(targetObject, field.Value);

return targetObject;
}

Тепер нам потрібно подбати про те, щоб метадані масивів об'єктів коректно зберігалися при серіалізації. Для цього напишемо елементарну перевантаження методу генерації типу:

/// < summary>
/// Генерує масив об'єктів з метаданими типу у відповідності з заданими атрибутами <see cref="AmfObjectAttribute"/>, з полями типу, заданого в атрибутах <see cref="AmfMemberAttribute"/>.
/ / / < /summary>
/ / / < param name="sourceObjects">Масив вихідних об'єктів.</param>
/ / / < returns></returns>
private static object[] GenerateType(object[] sourceObjects)
{
for (int i = 0; i < sourceObjects.Length; i++) sourceObjects[i] = GenerateType(sourceObjects[i]); // Генеруємо типи для кожного елемента масиву.
return sourceObjects;
}

Тепер ми вже можемо сериализации будь-які об'єкти та масиви в AMF для подальшої відправки на сервер. Але нам ще необхідний функціонал, що забезпечує відправку асоціативних масивів AMF, яких в C# попросту немає. У ролі асоціативних масивів тут виступають різні реалізації словників IEnumerable<KeyValuePair<TKey, TValue>, а так само хэштаблицы Hashtable, AMF ж розпізнає кожен ключ словника як у полі типу Array. Для реалізації рішення цієї задачі напишемо ще одну перевантаження, яка зможе правильно згенерувати словник AMF на основі ключів і значень словника:

/// < summary>
/// Визначає, чи є тип словником або спадкоємцем від словника. По суті це костыльная заглушка, щоб змусити перевантаження для генерації асоціативного масиву перекривати перевантаження для генерації інших object, інакше на виході завжди будемо отримувати масив пар, не типовий для AMF.
/ / / < /summary>
/ / / < param name="sourceType">Перевіряється тип.</param>
/ / / < returns></returns>
private static bool IsDictionary(this Type sourceType)
{
Type type0 = typeof(IEnumerable<KeyValuePair<string, object>>);
Type type1 = typeof(IDictionary<string, object>);
Type type2 = typeof(Dictionary<string, object>);

return sourceType.FullName == type0.FullName || sourceType.IsSubclassOf(type0)
|| sourceType.FullName == type1.FullName || sourceType.IsSubclassOf(type1)
|| sourceType.FullName == type2.FullName || sourceType.IsSubclassOf(type2);
}

/// <summary>
/// Генерує об'єкт на основі колекції пар "ключ-значення", де, ключ - ім'я поля об'єкта.
/ / / < /summary>
/ / / < param name="fields">Колекція полів майбутнього об'єкта виду "ключ-значення"</param>
/ / / < returns></returns>
private static object GenerateType(IEnumerable<KeyValuePair<string, object>> fields)
{
AssemblyName assemblyName = new AssemblyName("AmfDynamicAssemblyForDictionary"); // Створюємо нове середовище виконання коду.
AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.RunAndSave); // Визначаємо середовище виконання.
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName.Name, assemblyName.Name + ".dll"); // Визначаємо новий модуль для середовища виконання.
TypeBuilder typeBuilder = moduleBuilder.DefineType(typeof(Array).FullName, TypeAttributes.Public); // Опледеляем тип з нашим ім'ям.

ConstructorBuilder ctor = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); // Визначаємо конструктор.
ILGenerator ctorIL = ctor.GetILGenerator(); // Отримуємо посилання на генератор HTML-інструкцій для конструктора.
ctorIL.Emit(OpCodes.Ldarg_0); // Поміщаємо в стек обчислень нульовою аргумент. 
ctorIL.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)); // Викликаємо базовий конструктор для ініціалізації значення за промовчанням нульового аргументу.
ctorIL.Emit(OpCodes.Ret); // Поміщаємо в стек обчислень інструкцію про повернення з методу.

// Перебираємо всі поля нашого типу.
foreach (KeyValuePair<string, object> pair in fields)
{
object fieldValue = pair.Value; // Отримуємо значення поля.
Type fieldType = fieldValue.GetType(); // Отримуємо метадані типу поля.

// Якщо у типу заданий атрибут...
if (fieldType.IsDefinedAttribute<AmfObjectAttribute>())
{
fieldValue = GenerateType(fieldValue); // Генеруємо об'єкт типу, заданого в атрибуті.
fieldType = fieldValue.GetType(); // Оновлюємо тип поля.
}

typeBuilder.DefineField(pair.Key, fieldType, FieldAttributes.Public); // Визначаємо нове поле.
}

object targetObject = Activator.CreateInstance(typeBuilder.CreateType()); // Створюємо інстанси нашого динамічного типу.

// Розставляємо значення всіх властивостей об'єкта.
foreach (KeyValuePair<string, object> pair in fields) targetObject.GetType().GetField(pair.Key).SetValue(targetObject, pair.Value);

return targetObject;
}

Зверніть увагу, тут я створюю окрему динамічну збірку і при кожному виклику методу переопределяю в ній метадані. Це необхідно для того, щоб всі метадані нашого словника генерувалися заново, інакше через якийсь час виконання у примірників наших словників буде ціла купа невизначених полів, яких в типі бути не повинно і які будуть займати місце в пам'яті.

Серіалізація в AMF
Тепер вже точно все готово для серіалізації об'єктів в AMF. Приступимо до реалізації сериализатора:

/// < summary>
/// Серіалізует об'єкт в буффер AMF.
/ / / < /summary>
/ / / < param name="sourceObject">Вихідний об'єкт.</param>
/ / / < param name="version">Версія AMF.</param>
/ / / < returns></returns>
public static byte[] SerializeToAmf(this object sourceObject, ushort version)
{
using (MemoryStream memoryStream = new MemoryStream()) // Відкриваємо потік для запису даних в буфер.
using (AMFSerializer amfSerializer = new AMFSerializer(memoryStream)) // Ініціалізуємо сериализатор для AMF.
{
AMFMessage amfMessage = new AMFMessage(version); // Створюємо повідомлення для передачі сервера з заданим номером версії AMF.
AMFBody amfBody = new AMFBody(AMFBody.OnResult, null, GenerateType(sourceObject)); // Створюємо тіло для повідомлення AMF.

amfMessage.AddBody(amfBody); // Додаємо body для повідомлення AMF.
amfSerializer.WriteMessage(amfMessage); // Сериализуем повідомлення.

return memoryStream.ToArray(); // Перетворює потік пам'яті в буфер і повертає.
}
}

/// <summary>
/// Серіалізует об'єкт в буффер AMF3.
/ / / < /summary>
/ / / < param name="sourceObject">Вихідний об'єкт.</param>
/ / / < returns></returns>
public static byte[] SerializeToAmf(this object sourceObject) => sourceObject.SerializeToAmf(3);

/// <summary>
/// Серіалізует об'єкт у файл *.amf.
/ / / < /summary>
/ / / < param name="sourceObject">Сериализуемый об'єкт.</param>
/ / / < param name="path">Шлях збереження.</param>
/ / / < param name="version">Номер версії AMF.</param>
public static void SerializeToAmf(this object sourceObject, string path, ushort version)
=> File.WriteAllBytes($"{path}.amf", sourceObject.SerializeToAmf(version));

/// <summary>
/// Серіалізует об'єкт у файл *.amf. Версія AMF дорівнює 3.
/ / / < /summary>
/ / / < param name="sourceObject">Сериализуемый об'єкт.</param>
/ / / < param name="path">Шлях збереження.</param>
public static void SerializeToAmf(this object sourceObject, string path) => sourceObject.SerializeToAmf(path, 3);

Реалізація майже тривіальна. Єдиний нюанс — я навмисно створив перевантаження з номером версії AMF рівним за замовчуванням трьом. Коли ми працюємо з Flex, це практично завжди AMF3, а значить, і немає сенсу передавати у викликах методу серіалізації додатковий аргумент.

Для десеріалізації необхідно виконати всі вищеописані процедури, тільки в зворотному порядку:

/// < summary>
/// Десериализует буфер даних в об'єкт AMF.
/ / / < /summary>
/// <typeparam name="T">Тип десериализуемого об'єкта.</typeparam>
/ / / < param name="sourceBuffer">Вихідний буфер даних об'єкта.</param>
/ / / < returns></returns>
public static T DeserializeFromAmf<T>(this byte[] sourceBuffer) where T : class
{
using (MemoryStream memoryStream = new MemoryStream(sourceBuffer)) // Відкриваємо потік для читання даних з буфера.
using (AMFDeserializer amfDeserializer = new AMFDeserializer(memoryStream)) // Ініціалізуємо десериализатор для AMF.
{
AMFMessage amfMessage = amfDeserializer.ReadAMFMessage(); // Отримуємо повідомлення AMF.
AMFBody amfBody = amfMessage.GetBodyAt(0); // Отримуємо body з повідомлення AMF.

object amfObject = amfBody.Content; // Отримуємо об'єкт body AMF.
Type amfObjectType = amfObject.GetType(); // Отримуємо метадані типу об'єкта AMF.

// Формуємо запит на отримання всієї колекції потрібних нам типів з заданими атрибутами.
IEnumerable<Type> types = from type in Assembly.GetExecutingAssembly().GetTypes()
where Attribute.IsDefined(type, typeof(AmfObjectAttribute))
select type;

Type currentType = null; // Визначаємо поточний тип об'єкта з нашої збірки.

// Проходимо по всіх знайдених типів з нашим атрибутом.
foreach (Type type in types)
{
AmfObjectAttribute attribute = type.GetAttribute<AmfObjectAttribute>(); // Отримуємо наш атрибут.

if (attribute == null || attribute.Name != amfObjectType.FullName) continue; // Якщо в атрибуті задано інше ім'я - пропускаємо ітерацію.

currentType = type; // Інакше зберігаємо поточний тип об'єкта.
break;
}

if (currentType == null) return default(T); // Якщо тип не знайдено повертаємо null.

object targetObject = Activator.CreateInstance(currentType); // Створюємо інстанси нашого типу.

// Аналізуємо всі властивості нашого класу.
foreach (PropertyInfo propertyInfo in currentType.GetProperties())
{
AmfMemberAttribute attribute = propertyInfo.GetAttribute<AmfMemberAttribute>(); // Отримуємо наш кастомный атрибут.

if (attribute == null) continue; // Якщо атрибут не вказаний - пропускаємо.

propertyInfo.SetValue(targetObject, amfObjectType.GetProperty(attribute.Name).GetValue(amfObject, null, null); // Отримуємо значення властивості у десериализуемого об'єкта і зберігаємо його властивості нашого об'єкта.
}

// Аналізуємо всі поля нашого класу.
foreach (FieldInfo fieldInfo in currentType.GetFields())
{
AmfMemberAttribute attribute = fieldInfo.GetAttribute<AmfMemberAttribute>(); // Отримуємо наш кастомный атрибут.

if (attribute == null) continue; // Якщо атрибут не вказаний - пропускаємо.

fieldInfo.SetValue(targetObject, amfObjectType.GetField(attribute.Name).GetValue(amfObject)); // Отримуємо значення поля у десериализуемого об'єкта і зберігаємо його в полі нашого об'єкта.
}

return targetObject as T; // Призводить до типу T і повертає поточний об'єкт.
}
}

/// <summary>
/// Десериализует об'єкт з файлу *.amf.
/ / / < /summary>
/// <typeparam name="T">Тип десериализуемого об'єкта.</typeparam>
/ / / < param name="obj">Десериализуемый об'єкт.</param>
/ / / < param name="path">Шлях до файлу об'єкта.</param>
/ / / < returns>Десериализованный об'єкт AMF.</returns>
public static T DeserializeFromAmf<T>(this object obj, string path) where T : class => File.ReadAllBytes($"{path}.amf").DeserializeFromAmf<T>();

Тепер давайте подивимося, як можна самим швидким способом сериализации об'єкт в AMF і відправити його на сервер:

using (MemoryStream memoryStream = new MemoryStream()) // Резервуємо буффер для запису нашого об'єкта і звільняємо пам'ять по завершенню роботи методу.
using (AMFWriter amfWriter = new AMFWriter(memoryStream)) // Відкриваємо на запис буфер для AMF.
using (WebClient client = new WebClient()) // Відкриваємо HTTP-сокет для відправки даних на сервер (можна і через низькорівневий HttpWebRequest).
{
amfWriter.WriteBytes(new CustomAmfObject().SerializeToAmf()); // Записуємо дані об'єкта в буфер.
client.Headers[HttpRequestHeader.ContentType] = "application/x-amf"; // Додаємо заголовок ContentType запит.

byte[] buffer = client.UploadData(Host, "POST", memoryStream.ToArray()); // Відправляємо дані на сервер.
}

На скріншоті нижче можна спостерігати структуру сериализованного об'єкта, готового до відправлення на сервер:

image

У випадку, якщо сервер після відправки даних буде лаятися на те, що об'єкт не реалізує інтерфейс IExternalizable, в наші кастомні класи необхідно імплементувати реалізацію IExternalizable:

[AmfObject("example.game.gameObject")]
public class CustomAmfObject : IExternalizable
{
[AmfMember("x")] public float X { get; set; }
[AmfMember("y")] public float Y { get; set; }
[AmfMember("z")] public float Z { get; set; }

public CustomAmfObject(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}

public CustomAmfObject() : this(0f, 0f, 0f) { }

public void ReadExternal(IDataInput input)
{
X = input.ReadFloat();
Y = input.ReadFloat();
Z = input.ReadFloat();
}

public void WriteExternal(IDataOutput output)
{
output.WriteFloat(X);
output.WriteFloat(Y);
output.WriteFloat(Z);
}
}

Як показали тести в реальних умовах, для серіалізації даних через такий підхід зовсім не обов'язкова ланцюжок записів у IDataOutput і читання з IDataInput, всі дані і без них відправлялися на сервер у коректному вигляді. Для особливо ж «ругливых» серверів таке рішення може виявитися досить корисним.

Післямова
Описаний вище спосіб розв'язання задачі є далеко не єдиним з тих, що не потребують втручання у вихідний код оригінального фреймворка. Наприклад, ми могли б взагалі обійтися без атрибутів і роботи з метаданими з коду, якщо б реалізовували примірники наших об'єктів за допомогою DynamicObject, ExpandoObject і інших dynamic-неприємностей, покладаючи при цьому всю роботу з метаданими DLR.

Бази знань, викладеної в статті, повинно вистачити для написання сериализатора практично під будь-які завдання, або простого інтерпретатора/компілятора на основі HTML і рефлексії, а так само більш наочного розуміння принципу серіалізації в AMF.

Вихідні коди до статті доступні тут.
NuGet-пакет для розширення FluorineFx можна знайти на тут.

Сподіваюся, викладений мною матеріал був для Вас корисним.
Дякую за увагу!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.