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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

В данной статье мы разберем проблему и ее решение в виде 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

Реализация

Источник: https://habr.com/ru/articles/808515/


Интересные статьи

Интересные статьи

Японская киберполиция из префектуры Кочи во время рейда по выявлению продавцов дешёвых покемонов арестовала 36-летнего Ёсихиро Ямакаву по подозрению во взломе и редактировании файлов сохранений игр Po...
Миграции баз данных — это отличный способ безопасно обновить схему базы данных. Это именно то, что нам нужно в продакшене, ведь терять имеющиеся там данные крайне нежелательно. В этой статье я хочу по...
UNIX, SGI и динозавры. Обзор одного из самых необычных файловых менеджеров.
SOA (Сервис-Ориентированная Архитектура) строится путём комбинации и взаимодействия слабо-связанных сервисов. Для демонстрации создадим два приложения Клиент и Сервер. А их взаимодействие ...
Автоматизировал задание файловых ассоциаций, то есть выбор программы которая будет открывать файл из Explorer/Finder. И делюсь. Сначала проблематика… Файлы нужных расширений часто не открываются...