Как стать автором
Обновить

Миграция json файлов

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров4.6K

Для любого приложения у которого есть реальные пользователи и больше чем одна версия, рано или поздно встает вопрос версионирования данных и их миграции.

В данной статье мы разберем проблему и ее решение в виде open source плагина и трудностей, с которыми пришлось столкнуться в процессе!

TL;DR

Просто почитайте readme плагина, который я написал.

А если интересно как этот плагин разрабатывался, вот ссылка на Youtube плейлист с записями стримов, где я с нуля писал его ❤️

Об авторе

Меня зовут Алексей и я Lead разработчик. А последние полгода помогаю компании Zeptolab улучшать проект Overcrowded.

Я самоучка. Всё, что связано с разработкой игр, я изучал самостоятельно. Все мои знания - личный опыт. Работал над 5 мобильными проектами в разных жанрах (mid-core, simulator, merge-3). А также делал несколько проектов в дополненной реальности.

Проводя много времени за ответом на вопросы в unity чатиках в Telegram, я понял что тема архитектуры очень плохо освещена. А странные best practices от unity часто не имеют ничего общего с реальными проектами.

Потому сделал свой блог в Telegram, где пишу про архитектуру проектов на unity, присоединяйтесь!

Проблема

Представим ситуацию где вы работаете над новой, идеальной игрой. В ней уже есть кланы, караваны, но это только начало и вы работаете над добавлением нового контента.
Проходит несколько спринтов, ваша фича merge'ится в ветку разработки и вот-вот попадет в релиз.

И вот на стадии тестирования выясняется, что старые пользователи не могут запустить игру.

Вам приходит задача с описанием:

Шаги:
1. Скачать версию 1.0.0
2. Запустить игру, дождаться запуска обучения
3. Закрыть игру, установить версию 2.0.0
4. Запустить игру

Ожидаемое поведение:
- Игра запускается, открывается окно с обучением

Актуальное поведение:
- Игра зависает, в консоли ошибка JsonSerializationException

Доп. информация:
stack trace ошибки

После детального разбора ошибки вы вспоминаете как, во время написания фичи изменили тип поля в классе, отвечающий за хранение профиля игрока, а именно:

Было:

public class PlayerData
{
  public long Money;
}

Стало:

public class PlayerData
{
  public Disctionary<Currency, long> Money;
}

Отличное изменение! Теперь мы можем добавить сколько угодно видов валют в игру и все будет прекрасно работать.

И действительно, изменение хорошее, сложно с этим поспорить. Но вот только оно сломало обратную совместимость профиля.
Или по другому, совместимость с предыдущими версиями, которые уже есть.

Т.е. десериализатор просто не может преобразовать схему из:

{
  "Money": integer
}

В объект:

{
  "Money": object
}

Поняв, в чем проблема, моментально формулируете для себя способ решения:

  • Нужно взять данные пользователя старого формата и преобразовать их в новые формат, который совместим с версией 2.0.0

И двигаете баг в колонку ToDo.

Решение

Итерация 1

Погрузившись в детали формируется план действий:

  • Взять данные пользователя (из базы данных или persistent'ного хранилища)

  • Десериализовать старый формат

  • Сконвертировать в новый

  • Сохранить данные пользователя в новом формате

И тут вы понимаете, что есть проблема:

  • Чтобы сделать это, вам нужно создать копию класса PlayerData в котором будет другой тип поля Money

И решение будет выглядеть примерно так:

public enum Currency
{
    Soft,
    Hard
}

public class PlayerDataV1
{
  public long Money;
}

public class PlayerDataV2
{
  public Dictionary<Currency, long> Money;
}

var newDataInstance = new PlayerDataV2();
var oldRawJson = File.ReadAllText(path);
var oldData = JsonConvert.DeserializeObject<PlayerDataV1>(oldRawJson);

newDataInstance.Money[Currency.Soft] = oldData.Money;
var newData = JsonConvert.SerializeObject<PlayerDataV2>(newDataInstance);
File.WriteAllText(path, newData);

Нус, задача решена. Можно отправлять на ревью и брать следующий баг в работу.

Анализ решения

Решение выше может и исправляет баг, но делает это крайне не эффективно:

  1. Мы вынуждены были создать дубликат класса. Который в реальных проектах может иметь внутри себя сколько угодно полей.

  2. Данное решение - временный костыль, т.к. для версии 3.0.0 придется повторять схему.
    Что как максимум, может привести в блокировке основного потока больше чем на 5 секунд и вы получите ANR

Получив данные комментарии от коллег по команде или додумавшись до данных проблем самостоятельно, вы решаете сделать по другому.

Итерация 2

После анализа вы решаете исправить все, описанные выше проблемы, сформулировав для себя критерии:

  1. Классы-профили с данными игрока не должны дублироваться в коде

  2. Решение должно быть переиспользуемым для будущих версий

Чтобы удовлетворить данные критерии нужно:

  • Найти решение как сохранить данные, не вызвав исключения

И тут в голову приходит использовать разные имена для разных версий:

  • Вместо того чтобы в новой версии использовать имя Money , используете Currencies

  • А если Money больше нуля, добавлять значение в новый тип и обнулять Money

  • И поле Money можно будет пометить как Obsolete , чтобы другие разработчики не использовали его больше.

Повторять до бесконечности для каждой поломки обратной совместимости.

А решение само выглядит так:

public enum Currency
{
    Soft,
    Hard
}

public class PlayerData
{
  [Obsolete("Больше не используется. Оставить для обратной совместимости с версией 1.0.0. См. так же баг: AB-1234")]
  public long Money;
  public Dictionary<Currency, long> Currencies;
}

var rawJson = File.ReadAllText(path);
var playerData = JsonConvert.DeserializeObject<PlayerData>(rawJson);

if (playerData.Money > 0)
{
  playerData.Currencies[Currency.Soft] = playerData.Money;
  playerData.Money = 0;
}

Анализ решения

Уже на много лучше, на много компактнее, без дубликатор классов, до ANR как до луны, но:

  1. Старые поля навсегда останутся в файле и каждый раз будут участвовать в сериализации/десериализации

  2. Накладные расходы на обслуживание Obsolete атрибута
    Со временем кол-во полей помеченных атрибутом Obsolete вырастет в несколько раз и нужно будет каждый раз проверять чтобы никто случайно в эти поля ничего не записал или не использовать где-то в проекте.

В общем уходим в 3 итерацию

Наблюдение из опыта

На 3 проектах, которые начинал не я, данная проблема решалась смесью первого и второго решения. Что от проекта к проекту удивляло меня все больше и больше.
Дайте знать в комментах как у вас это сделано на проекте.

Итерация 3, финальная

Никаких дубликатов, классов, полей и Obsolete атрибутов - нужен другой подход, удовлетворяющий критериям:

  1. Классы-профили с данными игрока не должны дублироваться в коде

  2. Решение должно быть переиспользуемым для будущих версий

  3. Преобразование данных (миграция) из старой версии в новую должно быть унифицировано.
    Один единственный, понятный способ написания и поддержки миграций

  4. Решение должно создавать минимум накладных расходов

Ну и чтобы удовлетворить данным критериям мы:

  • Должны изолировать фичу и предоставить общий механизм, оптимизировав все накладные расходы

Изи, погнали!

Open Source плагин

Поиск аналогов

Наверное, прежде чем городить свои костыли, стоит поискать уже готовые решения.

Находим плагин Migrations.Json.Net. Все супер, но есть проблемы:

  1. Не понятно работает ли плагин на Unity и совместим ли с IL2CPP

  2. Решение выглядит очень круто, но вот реализация хромает
    Для такого простого решения много кода с кучей LINQ выражений

  3. Решение уже заброшено и maintainer вообще не отвечает на открытые Issue.
    Вот я в далеком 2021 отвечаю, что данный плагин корректно работает в Unity.

Есть и другие варианты, но они не такие популярные.

Технические требования

Продуктовые, если можно так назвать критерии мы сформулировали выше, теперь технические:

  • Решение должно иметь совместимость не только с unity, но и с другими версиями dotnet
    В моем случае я остановится на dotnet standard 2.0

  • Решение должно быть готово к использованию на production без доработок
    Продумать и покрыть всю логику работы мигратора тестами

  • Решение не должно ограничивать возможности Newtonsoft.Net.Json
    Т.е. использование методов Populate, настройки PreserveReferencesHandling, ObjectCreationHandling.Replace и атрибута JsonConstructor, должно быть сохранено

  • Решение должно thread-safety

  • Решение должно быть легкодоступным для скачивания и интеграции в проект
    Т.е. опубликовано в Nuget, openUPM и в release должен быть unitypackage для установки напрямую

  • Решение должно иметь автоматизированную проверку совместимости с версиями
    Тесты должны прогоняться как в dotnet, так и во всех LTS версиях unity, начиная с 2019.4

  • Решение должно иметь понятную, лаконичную документацию.
    Помимо xml-doc для всех публичных классов и методов, так же качественно оформить readme

  • Новое решение, должно объективно, путем замеров быть лучше, чем аналог
    Для этого нужно написать benchmark и приложить полученные цифры в readme

Реализация

👉Ссылка на финальную версию плагина на GitHub👈
Жмакайте на ⭐️, чтобы не потерять!
Версия 1.0.3 — production ready, можно смело внедрять к себе в проекты!

Собрав все требования и создав задачи в Projects (это простенькая Kanban доска встроенная прямо в GitHub), я приступил к реализации.

В общей сложности всю логику я писал около 15-17 часов. При том я это сделал в режиме реального времени, проводя стримы на youtube.

👉Ссылка на плейлист👈
p.s. на скорости 2x смотрится на одном дыхании!

По итогам финальное решение выглядит так:

  • Пользователю плагина, нужно пометить мигрируемый класс/структуру аттрибутом Migratable , указав в конструкторе текущую версию.
    Версия начинается с 0, т. е. по умолчанию можно считать что все классы имеют версию 0.
    И с версии 1, нам нужно будет реализовывать методы миграции.

  • Методы миграции — методы с определенной сигнатурой, которые будут вызываться плагином автоматически через рефлексию
    Сигнатура: private/protected static JObject Migrate_X(JObject data)
    Где X — номер версии на которую мы мигрируем JObject

  • Сам мигратор реализован как наследник JsonConverter, который:

    • При десерилизации вызывает методы миграции по порядку, начиная с версии, которая прописана в json файле

    • При сериализации берет версию из атрибута и записывает ее в файл
      Дополнительное поле в классе прописывать не нужно

  • Мигратор нужно проставить в настройки по умолчанию JsonConvert.DefaultSettings или добавить вручную при сериализации/десериализации

  • Если у вас версия json файла 3, а текущая версия класса 10, то мигратор сам вызовет методы с 4 по 10.
    Т. е. обновит формат json файла с 4 на 10.

А в коде это выглядит так:

public enum Currency
{
    Soft,
    Hard
}

[Migratable(1)]
public class PlayerData
{
    public Dictionary<Currency, int> Wallet;

    private static JObject Migrate_1(JObject rawJson)
    {
        var oldSoftToken = rawJson["soft"];
        var oldHardToken = rawJson["hard"];
    
        var oldSoftValue = oldSoftToken.ToObject<int>();
        var oldHardValue = oldHardToken.ToObject<int>();
        
        var newWallet = new Dictionary<Currency, int>
        {
            {Currency.Soft, oldSoftValue},
            {Currency.Hard, oldHardValue}
        };
        
        rawJson.Remove("soft");
        rawJson.Remove("hard");
        
        rawJson.Add("Wallet", JToken.FromObject(newWallet));

        return rawJson;
    }
}

  var jsonString = @"{
""soft"": 100,
""hard"": 10
}";
var migrator = new FastMigrationsConverterMock(MigratorMissingMethodHandling.ThrowException);
// Для десериализации
var deserializeResult = JsonConvert.DeserializeObject<PlayerData>(jsonString, migrator);
// Для сериализации
var serializeResult = JsonConvert.SerializeObject(deserializeResult, migrator);

// serializeResult: {"Wallet":{"Soft":100,"Hard":10},"JsonVersion":1}

Анализ решения

  • Вызов методов через рефлексию может быть не очевиден для пользователя

  • Плагин заставляет использовать методы с определенной сигнатурой и модификаторами доступа

  • Ошибка миграции из-за отсутствия метода с нужной сигнатурой обнаружится только во время исполнения

  • Цифры производительности оставляют желать лучшего из-за использования JObject.Load и рефлексии

  • При использовании MigratorMissingMethodHandling.Ignore каждый отсутствующий метод, будет искаться на объекте, что может привести в большой просадке производительности

Список возможных улучшений

  • Добавить Roslyn анализатор, который будет выдавать ошибку компиляции, если версия изменилась, а метода с нужной сигнатурой не реализован. Issue

  • Добавить Roslyn подсказку для автоматической генерации нужного метода. Issue

  • Кэшировать все методы с сигнатурой Migrate(JObject) при первом вызове. Issue

Буду рад любому вкладу!

Выводы

Что я лично для себя понял про подобного формата работу:

  • Нужно иметь выдержку и самодисциплину
    Честно, если бы я не пообещал и не начал всю эту активность со стримами, мне было бы сложно сохранить достаточный уровень мотивации чтобы закончить работу над плагином

  • Качественная упаковка open source плагина занимает около 50% времени от самой реализации.
    Я понимаю что плагин не большой, всего 400 строк кода, но я явно недооценил сколько времени уйдет на оформление, CI/CD и документацию.

  • Дисциплина кода, коммитов, архитектура даже для маленьких проектов важна.
    Иначе чтобы сделать нормальный CI/CD без боли, придется перелопатить половину проекта.

Подписывайтесь на мой Telegram канал, там я пишу про архитектуру unity проектов.
И часто подписываюсь на движухи, которые потом выливаются в Open Source проекты

Теги:
Хабы:
-4
Комментарии17

Публикации

Истории

Работа

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область