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

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

Комментарии (17)


  1. gudvinr
    28.04.2024 01:48

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


  1. D0001
    28.04.2024 01:48
    +1

    Изучать сторонний специализированный инструмент вместо того, чтобы продумать механизм миграций?


    1. dyadyaSerezha
      28.04.2024 01:48
      +8

      Нет, сделать N доморощеных решений вместо того, чтобы погуглить. Проблеме лет 60 или больше и есть разные решения, в зависимости от требований.

      Но главное - подписать на свой крутой телеграм канал! Во.


      1. vangogih Автор
        28.04.2024 01:48

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


  1. MonkAlex
    28.04.2024 01:48
    +2

    Предлагаю решение рядом - хранить данные не в json, а в sqlite.

    А как мигрировать данные в БД - куча готовых решений и\или хотя бы у всех больше опыта )


    1. randomsimplenumber
      28.04.2024 01:48
      +1

      Какая разница, json/sql/реестр windows. Функцию MigrateFrom1_0_To__1_1 кто-то должен написать. Никто кроме автора не знает, чем отличается 1.0 от 1.1. А если функция миграции есть - в чем проблема ее вызвать?


      1. MonkAlex
        28.04.2024 01:48
        +1

        Так автор описал же. Когда json - хочется просто делать сериализацию и десериализацию. Миграции не дают это делать удобно, пришлось прикрутить дополнительную либу к процессу.

        А когда у вас БД - то вы в любом случае не можете просто сделать "десериализацию", маппинг сущности на табличку придётся делать сознательно, данные мигрировать - сознательно =)


        1. randomsimplenumber
          28.04.2024 01:48

          Я уверен, что существуют готовые решения для сериализации в БД.


      1. breninsul
        28.04.2024 01:48

        есть готовые решения а-ля flyway (нз под .net), и мы пишем их на человеческом sql запуская 1 раз и код вообще не знает про все эти нюансы с деприкейтед полями, жизнь легка и проста. Ну т.е. есть возможность и классы написать, но для исключительных ситуаций это необходимо


    1. kolebynov
      28.04.2024 01:48

      А знаете какие-нибудь решения для миграции в БД, которые можно к Юнити подключить. Мне в голову только EF/EF Core приходит, но его вряд ли получиться к Юнити подключить, хотя попробовать можно :)


      1. MonkAlex
        28.04.2024 01:48

        Я юнити никогда не видел в глаза даже, не в курсе.

        Если там обычный дотнет - то EF Core должен отлично работать.


      1. withkittens
        28.04.2024 01:48

        Не знаю насчёт Unity (но не вижу причин, почему не должно работать), но можно посмотреть в сторону FluentMigrator.


    1. vangogih Автор
      28.04.2024 01:48

      Разработка игры как правило начинается с полной авторитарности клиента. Если только заранее не известно что 100% будет сервер.
      Потому выгодно по времени начать сохранять профиль игрока в локально, а лучший формат - json. БД для этого редко тянется, так же как и ORM'ки как NHibernate и/или EF.

      Безусловно, если есть сервер, то это, грубо говоря, не проблема клиентского разработчика.

      А так да, sqllite - хорошее решение, но я всего 1 раз встречал их в реальных игровых проектах (из 20-30 к которым так или иначе имел доступ).


  1. HexGrimm
    28.04.2024 01:48
    +1

    Привет. В таком подходе стоит обязать писать тесты со всеми старыми вариантами сигнатур, чтобы легче было вспомнить что вообще ранее было. А еще круче, использовать авто комбинаторику для таких данных, а в тесте делать NUnit.Assume(), чтобы проверить что миграция не отвалится на корнер кейсах.

    Так можно делать не только с json, а с любым нетипизированным набором.


  1. Akuma
    28.04.2024 01:48

    Может в геймдеве и правда все так плохо, хз, Сет сомневаюсь. В вебе давно придумали миграции.

    И зачем вы вообще что-то такое храните в json? На всех мобильных же есть встроенная БД, если не ошибаюсь SQLite. С миграциями не будет ни проблем, ни костылей.


    1. xFFFF
      28.04.2024 01:48

      Схема БД меняется легко, но перенос данных может быть болью. )


      1. gudvinr
        28.04.2024 01:48

        перенос данных может быть болью

        Если сознательно себе не вставлять палки в колёса, то нет.

        Можно либо всю бд sqlite сжать и отправить куда надо, либо сдампить нужные даблички в CSV, который опять же, куда угодно вставляется.