SerializeController
Namespace: Vortex.Core.Extensions.LogicExtensions.SerializationSystem
Сборка: ru.vortex.extensions
Платформа: .NET Standard 2.1+
Назначение
Сериализация и десериализация объектов в JSON-строку без внешних библиотек.
Возможности:
- Преобразование объекта в форматированную JSON-строку и обратно
- Работа по свойствам с getter + setter: public-getter по умолчанию, непубличные — при маркировке
[IsPOCO] - Контроль сериализуемости через атрибут
[POCO]на типах - Исключение отдельных свойств через
[NotPOCO] - Custom-конвертеры для типов вне стандартных правил (
RegisterCustomSerializer) - Поддержка вложенных объектов, коллекций, словарей
- Типобезопасная десериализация с валидацией через
IsAssignableFrom - Защита от циклических ссылок
- Поддержка
Nullable<T>,enum,DateTime,Guid
Вне ответственности:
- Сериализация полей (только свойства)
- Словари со сложными ключами (только простые типы и
Type) - Потокобезопасность
- Сериализация графов с множественными ссылками на один объект
Зависимости
| Зависимость | Назначение |
|---|---|
Vortex.Core.LoggerSystem |
Логирование ошибок через Log.Print() |
System.Reflection |
Обнаружение свойств и атрибутов |
System.Collections |
IDictionary, IList |
System.Globalization |
CultureInfo.InvariantCulture для чисел и DateTime |
Архитектура
Partial-класс SerializeController разбит на 5 файлов:
| Файл | Назначение |
|---|---|
SerializeController.cs |
Общая инфраструктура: кеши, POCO-валидация, парсинг JSON, простые типы |
SerializeControllerExtSerialization.cs |
Сериализация объектов в JSON-строку |
SerializeControllerExtDeserialization.cs |
Десериализация JSON-строки в новые объекты |
SerializeControllerExtUploading.cs |
Загрузка данных из JSON в существующие объекты |
SerializeControllerExtRegistration.cs |
Регистрация custom-конвертеров (RegisterCustomSerializer) |
Дополнительно — три атрибута в POCOAttribute.cs.
Атрибуты
| Атрибут | Цель | Применение |
|---|---|---|
[POCO] |
Маркер сериализуемого типа | Класс, структура, интерфейс |
[NotPOCO] |
Исключение свойства из сериализации | Свойство |
[IsPOCO] |
Принудительное включение непубличного свойства (internal/protected/private) в сериализацию | Свойство |
Правила фильтрации типов
| Категория | Правило |
|---|---|
| Простые типы (примитивы, string, enum, DateTime, Guid) | Сериализуются всегда |
Коллекции (IList, массивы) |
Сериализуются если тип элемента сериализуем |
Словари (IDictionary) |
Ключ — простой тип или Type; значение сериализуемо |
| Сложные типы | Только если тип помечен [POCO] |
Свойства с [NotPOCO] |
Пропускаются всегда |
[POCO] на интерфейсе распространяется на все его реализации.
Правила отбора свойств
Свойство попадает в сериализацию, если:
- есть getter и setter (видимость setter'а не важна — рефлексия вызовет любой);
- тип свойства сериализуем (см. правила фильтрации типов);
- отсутствует атрибут
[NotPOCO]; - getter публичный ИЛИ свойство помечено
[IsPOCO].
Непубличные свойства без [IsPOCO] в сериализацию не попадают — это позволяет держать
internal/private state класса (кеши, шедоу-стейты) изолированным от сейва, оставляя
наружу IReadOnly-фасады.
Custom-конвертеры
Для типов, не подпадающих под IsSimpleType и атрибут [POCO], регистрируются
custom-конвертеры через RegisterCustomSerializer. Зарегистрированное семейство типов
ведёт себя как simple-тип: сериализуется в JSON-строку и десериализуется обратно через
предоставленные функции.
SerializeController.RegisterCustomSerializer(
matches: type => typeof(ExtensibleEnum).IsAssignableFrom(type),
serialize: obj => ((ExtensibleEnum)obj).Key,
deserialize: (type, str) => ExtensibleEnum.Deserialize(type, str));
Применение — типы, чьи singleton-инстансы нельзя пересоздавать через
Activator.CreateInstance (например, type-safe enum-подобные классы), но которые имеют
стабильное строковое представление. Регистрация идемпотентна по комбинации трёх
делегатов (повторный вызов с теми же ссылками не дублирует запись), поэтому безопасна
из static-инициализатора при домен-релоадах.
Процесс сериализации
SerializeProperties(object)
SerializeClass(object, depth)
IsSimpleType? -> GetSimple()
Cyclic? -> Error, ""
IDictionary? -> SerializeDictionary()
IList/Array? -> SerializeArray()
IsPOCO(type)? -> GetReadablePropertiesList() -> рекурсия по свойствам
!IsPOCO(type)? -> Warning, "null"
Процесс десериализации
DeserializeProperties<T>(string)
DeserializeClass(Type, string)
IsSimpleType? -> SetSimple()
"null"? -> null
IDictionary? -> DeserializeDictionary()
Array? -> DeserializeArray()
IList? -> DeserializeCollection()
Object -> SeparateText() -> читает "__" маркер
Type.GetType() -> IsPOCO? -> Activator.CreateInstance()
fallback -> FormatterServices.GetUninitializedObject()
-> рекурсия по свойствам (пропуск [NotPOCO])
Процесс загрузки (Upload)
В отличие от десериализации, не создаёт новые экземпляры, а обновляет существующие. Ключи словарей, которых нет в данных, сохраняются (мерж).
UploadProperties<T>(string, T)
IDictionary? -> UploadDictionary() (мерж: существующие обновляются, новые добавляются)
Array? -> UploadArray() (пересоздаёт массив)
IList? -> UploadCollection() (clear + заполнение)
Object -> UploadClass() (обновляет свойства target)
пустые данные -> ClearClass() (сброс свойств в default/null)
свойство null -> fallback на DeserializeClass() (создание нового)
свойство есть -> рекурсивный Upload в существующий объект
Семантика контейнеров при Upload
Поведение принципиально разное по типу коллекции — это важно при выборе структуры сохраняемой модели:
| Контейнер | Семантика | Поведение для ключей/элементов, отсутствующих в сейве |
|---|---|---|
IDictionary<K,V> |
Merge | Сохраняются. Значения по существующим ключам обновляются через рекурсивный UploadClass, подписки на event'ы значения сохраняются. |
T[] (массив) |
Replace | Массив пересоздаётся строго размера из сейва, каждый элемент инстанцируется заново через Activator.CreateInstance. Существующая структура и все подписки на её элементы теряются. |
IList<T> (List) |
Replace | Clear() + заполнение из сейва. Элементы инстанцируются заново. |
| Object (POCO) | Merge | Свойства обновляются поверх существующего инстанса. Подписки на сам инстанс сохраняются. |
Implications для проектирования сохраняемой модели
«Эталонная структура» модели создаётся при ComplexModel<T>.Init() через
Activator.CreateInstance(t) для каждого зарегистрированного типа. Десериализация затем
накладывает значения из сейва поверх этой структуры. Если модель содержит коллекции,
размер/состав которых меняется между версиями конфига (например, добавился новый параметр
в ParametersMap после релиза), эволюция корректно работает только для Dictionary —
массивы и списки полностью перетираются сейвом и теряют новые элементы свежей структуры.
Правила выбора контейнера:
- Структурная коллекция с устойчивыми ключами (параметры из карты, реестр квестов,
навыки персонажа, словарь характеристик) →
Dictionary<K, V>, гдеK— стабильный идентификатор (string,enum,Guid). Тогда новые ключи из обновлённой карты автоматически появляются в загруженной модели с дефолтами, а старые удалённые из карты — отбрасываются на этапе мерджа (если ключа нет в карте — он просто не дополнит существующий target). - Списочные данные без идентичности (история событий, лог попыток, лидерборд)
→
T[]илиList<T>. Replace-семантика тут корректна — сейв полностью описывает актуальный список. - Фиксированная структура с именованными полями → отдельные
[POCO]-свойства, не массив/список. Каждое поле эволюционирует независимо: появилось новое свойство — берётся дефолт из конструктора и не затирается отсутствующим в сейве JSON-ключом.
Подписки на события и Upload
Merge-семантика Dictionary и Object сохраняет существующие инстансы — поэтому подписки
(event / Action), сделанные на этих инстансах до Upload, продолжают работать
после загрузки сейва. Replace-семантика массива/списка инстансы пересоздаёт — все
прежние подписки нужно либо восстанавливать вручную после Upload, либо избегать
структурных коллекций как мест хранения объектов с подписками.
Когда сейв содержит лишние/устаревшие данные
- Dictionary: существующий target-словарь пополняется новыми ключами из сейва, обновляются совпадающие. Ключи, которые есть в target, но отсутствуют в сейве — остаются (свежие из конфига). Ключи, которые есть в сейве, но не в target — добавляются (могут быть устаревшими, контроллер должен сам их отфильтровать при необходимости).
- Object: свойства из сейва, которых нет в текущем типе — пропускаются с warning (DebugMode), не ломают загрузку. Свойства типа, которых нет в сейве — сохраняют значение из эталонной структуры (дефолт конструктора).
- Array/List: содержимое строго из сейва. Текущая «эталонная» длина и элементы не влияют на результат.
Формат JSON
Каждый сложный объект содержит маркер типа "__" с AssemblyQualifiedName:
{
"__" : "MyApp.Player, MyAssembly, Version=1.0.0.0, ...",
"Name" : "Player1",
"Level" : 5,
"Items" : [
{
"__" : "MyApp.Item, MyAssembly, ...",
"Id" : "sword_01",
"Count" : 1
}
]
}
| Тип | Формат в JSON |
|---|---|
string |
"text" (с экранированием \", \\) |
int, long и др. целые |
42 |
float, double, decimal |
3.14 (InvariantCulture) |
bool |
true / false |
enum |
"Active" (строковое имя) |
DateTime |
"yyyy-MM-dd HH:mm:ss" (InvariantCulture, без timezone) |
Guid |
"00000000-0000-0000-0000-000000000000" |
null |
null |
Type (ключ словаря) |
"AssemblyQualifiedName" |
Кеширование
| Кеш | Содержимое |
|---|---|
CacheFields |
Dictionary<Type, PropertyInfo[]> — свойства типа (с учётом [NotPOCO] и IsSerializableType) |
CachePOCO |
Dictionary<Type, bool> — результат проверки [POCO] на типе и его интерфейсах |
Оба кеша заполняются при первом обращении и не очищаются. Реестр посещённых объектов VisitedObjects (защита от циклов) — напротив, очищается в начале каждого SerializeProperties и в finally по завершении.
Интеграция с фреймворком
ComplexModel<T> — использует SerializeProperties() / DeserializeProperties<T>() для персистенции составных моделей (Dictionary<Type, T>). Интерфейс T может быть помечен [POCO], тогда все его реализации автоматически сериализуемы.
RecordScriptGenerator — генерирует код сохранения Record-подклассов:
public override string GetDataForSave() => this.SerializeProperties();
public override void LoadFromSaveData(string data)
{
var temp = data.DeserializeProperties<MyRecord>();
this.CopyFrom(temp);
}
Контракт
Вход
Объект, тип которого помечен [POCO] (или реализует интерфейс с [POCO]), со свойствами, у которых есть getter + setter: public-getter по умолчанию, непубличные — при маркировке [IsPOCO].
Публичный API
| Метод | Назначение |
|---|---|
string SerializeProperties(this object) |
Сериализация объекта в JSON-строку |
T DeserializeProperties<T>(this string) |
Десериализация JSON-строки в новый экземпляр |
void UploadProperties<T>(this string data, T target) |
Загрузка данных JSON в существующий объект target (мерж, без пересоздания) |
void RegisterCustomSerializer(matches, serialize, deserialize) |
Регистрация custom-конвертера для семейства типов |
Выход
- Сериализация: форматированная JSON-строка с маркерами типов
- Десериализация: типизированный экземпляр объекта или
default(T)при ошибке - Upload: данные накладываются на переданный
target(возвратvoid)
Поддерживаемые типы свойств
| Категория | Типы |
|---|---|
| Числа | int, long, short, byte, sbyte, uint, ulong, ushort, float, double, decimal |
| Текст | string, char |
| Логические | bool |
| Дата/Идентификаторы | DateTime, Guid |
| Перечисления | Любой enum |
| Nullable | Nullable<T> для всех простых типов |
| Коллекции | T[], List<T>, любой IList (если элемент сериализуем) |
| Словари | Dictionary<K,V> — ключ: простой тип или Type; значение: сериализуемый тип |
| Вложенные объекты | Рекурсивно — только типы с [POCO] |
Гарантии
- Типы без
[POCO]не сериализуются (warning в лог, требует включённого DebugMode) - Свойства с
[NotPOCO]игнорируются в обоих направлениях - Несериализуемые типы свойств отсекаются на этапе сбора
PropertyInfo[] - Десериализация проверяет совместимость типов через
IsAssignableFrom - Циклические ссылки обнаруживаются через
HashSet<object> - Числа с плавающей точкой через
InvariantCulture - Неизвестные свойства в JSON при десериализации пропускаются с warning (не ломают загрузку)
- Warning-сообщения выводятся только при включённом DebugMode (
SettingsModelExtDebug)
Ограничения
| Ограничение | Причина |
|---|---|
| Только свойства, не поля | Контроль сериализуемой поверхности, фильтрация системных полей |
Сложные типы требуют [POCO] |
Защита от затягивания UnityEngine.Object и прочих непредназначенных типов |
| Нет циклических ссылок | Обнаружение через HashSet; цикл — ошибка |
Ключи словарей — простые типы или Type |
Сложные ключи не поддерживаются |
Type.GetType() при десериализации |
Тип должен быть доступен в текущем AppDomain |
DateTime без timezone |
Фиксированный формат yyyy-MM-dd HH:mm:ss |
VisitedObjects — static |
Не потокобезопасен |
Использование
Разметка типов
[POCO]
public class PlayerData
{
public string Name { get; set; }
public int Level { get; set; }
public float Score { get; set; }
}
Исключение свойств
[POCO]
public class QuestModel : Record
{
public QuestState State { get; internal set; }
public byte Step { get; internal set; }
// Не сериализуется — неизменяемые данные из пресета
[NotPOCO] public bool Autorun { get; private set; }
[NotPOCO] public bool UnFailable { get; internal set; }
// Не сериализуется — QuestLogic не помечен [POCO]
public QuestLogic[] Logics { get; private set; }
}
Разметка через интерфейс
[POCO]
public interface IGameData { }
// Автоматически сериализуем — реализует интерфейс с [POCO]
public class InventoryData : IGameData
{
public int Gold { get; set; }
public int Gems { get; set; }
}
Сериализация
var player = new PlayerData { Name = "Test", Level = 42, Score = 3.14f };
string json = player.SerializeProperties();
Десериализация
PlayerData restored = json.DeserializeProperties<PlayerData>();
Полиморфизм
Десериализация восстанавливает конкретный тип из маркера "__". Целевой тип T может быть базовым классом или интерфейсом:
IGameData data = json.DeserializeProperties<IGameData>();
// data будет конкретного типа, записанного в "__"
Интеграция с SaveSystem через Record
[POCO]
public class PlayerRecord : Record
{
public string Name { get; set; }
public int Level { get; private set; }
// Ссылка на ScriptableObject — не затянется (тип без [POCO])
public WeaponConfig Weapon { get; set; }
public override string GetDataForSave() => this.SerializeProperties();
public override void LoadFromSaveData(string data)
=> this.CopyFrom(data.DeserializeProperties<PlayerRecord>());
}
Интеграция с ComplexModel
[POCO]
public interface IGameData { }
public class GameModel : ComplexModel<IGameData>
{
// Все реализации IGameData автоматически сериализуемы
}
Граничные случаи
| Ситуация | Поведение |
|---|---|
null на входе SerializeProperties |
string.Empty |
Тип без [POCO] |
LogLevel.Warning, "null" (сериализация) / null (десериализация) |
Свойство с [NotPOCO] |
Пропускается в обоих направлениях |
| Свойство с несериализуемым типом | Отфильтровано из PropertyInfo[], не попадает в JSON |
| Циклическая ссылка (A.B = obj, A.C = obj) | LogLevel.Error, "" |
| Несовместимый тип при десериализации | LogLevel.Error, default(T) |
Отсутствует маркер "__" в JSON |
LogLevel.Error, null |
| Свойство без setter | Пропускается при сборе свойств |
| Свойство есть в JSON, но удалено из типа | LogLevel.Warning, пропуск (не ломает загрузку) |
| Свойство есть в JSON, но без setter | LogLevel.Warning, пропуск |
| Пустая строка при десериализации | null |
null-элемент в коллекции |
Пропускается при десериализации |
[POCO] на интерфейсе |
Все реализации считаются сериализуемыми |
| Тип без конструктора по умолчанию | Fallback на FormatterServices.GetUninitializedObject() — поля не инициализируются конструктором |