Для любого приложения у которого есть реальные пользователи и больше чем одна версия, рано или поздно встает вопрос версионирования данных и их миграции.
В данной статье мы разберем проблему и ее решение в виде 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);
Нус, задача решена. Можно отправлять на ревью и брать следующий баг в работу.
Анализ решения
Решение выше может и исправляет баг, но делает это крайне не эффективно:
Мы вынуждены были создать дубликат класса. Который в реальных проектах может иметь внутри себя сколько угодно полей.
Данное решение - временный костыль, т.к. для версии 3.0.0 придется повторять схему.
Что как максимум, может привести в блокировке основного потока больше чем на 5 секунд и вы получите ANR
Получив данные комментарии от коллег по команде или додумавшись до данных проблем самостоятельно, вы решаете сделать по другому.
Итерация 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 как до луны, но:
Старые поля навсегда останутся в файле и каждый раз будут участвовать в сериализации/десериализации
Накладные расходы на обслуживание
Obsolete
атрибута
Со временем кол-во полей помеченных атрибутомObsolete
вырастет в несколько раз и нужно будет каждый раз проверять чтобы никто случайно в эти поля ничего не записал или не использовать где-то в проекте.
В общем уходим в 3 итерацию
Наблюдение из опыта
На 3 проектах, которые начинал не я, данная проблема решалась смесью первого и второго решения. Что от проекта к проекту удивляло меня все больше и больше.
Дайте знать в комментах как у вас это сделано на проекте.
Итерация 3, финальная
Никаких дубликатов, классов, полей и Obsolete
атрибутов - нужен другой подход, удовлетворяющий критериям:
Классы-профили с данными игрока не должны дублироваться в коде
Решение должно быть переиспользуемым для будущих версий
Преобразование данных (миграция) из старой версии в новую должно быть унифицировано.
Один единственный, понятный способ написания и поддержки миграцийРешение должно создавать минимум накладных расходов
Ну и чтобы удовлетворить данным критериям мы:
Должны изолировать фичу и предоставить общий механизм, оптимизировав все накладные расходы
Изи, погнали!
Open Source плагин
Поиск аналогов
Наверное, прежде чем городить свои костыли, стоит поискать уже готовые решения.
Находим плагин Migrations.Json.Net. Все супер, но есть проблемы:
Не понятно работает ли плагин на Unity и совместим ли с IL2CPP
Решение выглядит очень круто, но вот реализация хромает
Для такого простого решения много кода с кучей LINQ выраженийРешение уже заброшено и 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
Реализация