Посмотрел я накануне это видео и «вдохновился»… тем, что в видео показан очень сложный способ сделать очень простую вещь — миграцию пользовательских данных.
Если интересен контекст моего баттхерта, то стоит посмотреть оригинальное видео, если же нет — можно перейти сразу сюда к моей реализации.
В примере на каждую новую версию создается новая модель данных, что порождает пару проблем:
-
тип данных изменился, следовательно во всем коде вместо обращения в GameDataV1 мне придется переписывать тип данных на GameDataV2. А это, как правило, сотни ссылок даже на гипер/гибрид/гидра/гига/или-как-их-там-сейчас казуальных играх
-
в разы больше проблем, если весь игровой код будет обращаться к GameStateBase (больше я не вижу причин вводить этот родительский класс) и каждое обращение к игровым данным будет c явным привидением в виде int someData = ((GameDataV2)data).Value;
-
куча неиспользуемого кода — если я 100 раз изменю данные, то у меня в коде будет 100 «лишних» классов, которые использовались буквально один раз. Не считая кода самих миграций, но он в любом случае будет
Поэтому здесь я покажу более простой способ реализации миграций без лишних иерархий и написания новой модели данных под каждую версию. Конечно, способ «более просто» только относительно — для него надо уметь пользоваться не только JsonUtility, а еще Newtonsoft.Json (то есть пролистнуть документацию или посмотреть 10-минутный тутор). Но было бы странно браться за миграцию, если вам и такое не под силу.
Кстати, если сразу использовать Newtonsoft и (по какой-то причине) оставлять все типы данных при обновлении, то можно написать кастомные JsonConverter’ы и избежать всех велосипедов из видео.
Дисклеймер:
-
Текущая реализация написана исключительно для того, чтобы понять концепцию. В ней пропущены многие проверки и нюансы ради лаконичности. Конечная реализация обычно ориентируется на конкретный проект и сильнее защищается от возможных багов.
-
Также на реальном проекте, вместо написания очередных костылей, можно использовать готовые решения вроде FastMigrations.Json.Net. Которые сэкономят время и будут более оптимизированными. Хотя в данной реализации мне не нравится, что все миграции надо писать прямиком в модели.
-
Мне все еще очень нравится идея оригинального видео, так как она подсвечивает частую проблему на проектах и я не видел подобных роликов. Но кроме идеи, мне в нем ничего не нравится. Сама идея написания статья родилась из страха, с которым представляю толпу маслят, которые бездумно копипастят свои PlayerData/SaveData/GameData перед каждым обновлением проекта.
-
Оригинальный подход может подойти (и то, мне кажется, есть более удобные варианты), если вы делаете крошечные проекты под веб, у вас каждый килобайт на счету, а newtonsoft занимает целых 250 КБ в конечном билде.
Перед началом душной технички можете заскочить на мой телеграм-канал или, если от миграции игровых данных вас отделяет отсутствие работы, даже задуматься о менторстве.
Поехали
Пример возьмем аналогичный. Есть 3 версии игровых данных, ведь, как это часто бывает, мы не всегда готовы к предстоящим изменениям в ТЗ. Дальше отойдем от вырожденного примера в видео, создадим новую ветку и изменим в ней безвозвратно нашу модель. Затем повторим это для третьей версии. То есть, воспроизведем реальный рабочий кейс — мы выпустили первую версию и сделали две новых.
Таким образом, ваш гит будет выглядеть примерно так:
Следовательно, миграции будем проверять честно: сохраняя данные на нулевой версии, и перескакивая на ветки с версией 1 и 2. В конце проверим случай с «пользователем», который поиграл на нулевой версии, и обновился сразу на вторую, то есть перескочил одну версию. В таком случае должны отработать обе миграции: с версии 0 на версию 1 и с 1 на 2.
Как будет выглядеть модель игровых данных в разных версиях:
-
Храним в одном поле имя персонажа, количество дерева и камня, и ,конечно же , версию, по которой будем прогонять миграции:
[Serializable] public class GameData { public string Version = "0.1.0"; public string PlayerName; public int Wood; public int Stone; }
2. Затем бравые геймдизайнеры решили, что у персонажа имя и фамилия должны быть отдельно:
[Serializable] public class GameData { public string Version = "0.1.0"; public string FirstName; public string LastName; public int Wood; public int Stone; }
3. Теперь добавляем больше ресурсов и храним их в списке, а не втупую отдельными полями. Ресурсы положим в отдельный класс, а не словарь, так как уже за 2 версии научились не верить ГД на слово и сразу готовимся к тому, что в ресурсы добавятся еще данные. А еще потому что в инспекторе словарь рисовать неудобно.
[Serializable] public class GameData { public string Version = "0.1.0"; public string FirstName; public string LastName; public List<Resource> Resources = new(); } [Serializable] public class Resource { public string Name; public int Amount; }
Теперь напишем интерфейс для наших миграций и общий класс, который будет те самые миграции выполнять:
public interface IMigration { Version ToVersion { get; } JObject Migrate(JObject data); }
Подробнее про JObject можно прочитать здесь, здесь и здесь. Если по тупому (иначе и не могу), то это состояние данных где-то посередине между сырой строкой и конечным классом. Мы можем по ключам, как в словаре, получать доступы к полям в JSON объекте и менять их как нам захочется. И все что нам надо будет сделать в реализациях этого интерфейс — привести данные из одной версии модели в другую. То есть добавить новые поля, заполнить значениями из старых и удалить старые. Хотя последнее не обязательно — они и сами затрутся при следующем сохранении — но для красоты и уменьшения энтропии в коде удалим сразу.
В качестве версии во время миграций будем использовать System.Version. В нем сразу версия представлена в удобном формате (major.minor.build) и уже реализованы методы для сравнения (чтобы не пришлось самому определять, какая версия новее: «1.2.3» или «1.3.2»)
Теперь сам сервис для миграций:
public class Migrator { private readonly IOrderedEnumerable<IMigration> _migrations; public Migrator(params IMigration[] migrations) => _migrations = migrations.OrderBy(x => x.ToVersion); public T Execute<T>(string rawData) { JObject data = JObject.Parse(rawData); Version version = new(data["Version"]!.ToString()); if (version.CompareTo(_migrations.Last().ToVersion) >= 0) return Parse<T>(data); foreach (IMigration migration in _migrations) { if (version.CompareTo(migration.ToVersion) < 0) { data = migration.Migrate(data); data["Version"] = migration.ToVersion.ToString(); version = migration.ToVersion; } } return Parse<T>(data); } private T Parse<T>(JObject data) => JsonConvert.DeserializeObject<T>(data.ToString()); }
Тут все просто:
-
в конструктор подаем сами миграции (которые добавляем к необходимым версиям игры) и сортируем их по возрастанию версии. System.Version уже реализует интерфейс IComparable, поэтому OrderBy сам разберется какая ToVersion новее.
-
при выполнении метода Execute сначала проверим, что версия не старше последней, подлежащей миграции. Тогда данные будут считаться новыми и можем спокойно их распарсить и использовать дальше
-
Затем для всех миграций подряд делаем аналогичную проверку. Если версия указанная в сохранении младше той, что указана в миграции, то эту самую миграцию и выполняем. Затем обновляем версию в сохранении.
-
В конце точно также парсим из JSON наши, актуальные после миграций, данные.
Здесь стоит сразу рассказать как работает метод Version.CompareTo:
если версии равны, возвращает 0
если версия, которую сравнивают (для которой вызывается метод, которая как бы «слева» от CompareTo) новее, то возвращает 1
если старше — то -1
Наши миграции
Как мы помним, в первом измении было принято решение разделить имя и фамилию игрока в отдельные поля вместо общего PlayerName. Предположим, все игроки молодцы и изначально писали имя игрока просто в два слова, чтобы нам потом было удобно делить. Значит, наша первая миграция будет выглядеть так:
public class Migration_1_2 : IMigration { public Version ToVersion { get; } = new Version(0, 2, 0); public JObject Migrate(JObject data) { string fullName = data["PlayerName"].ToString(); string[] names = fullName.Split(' '); data["FirstName"] = names[0]; data["LastName"] = names[1]; data.Remove("PlayerName"); return data; } }
При следующем обновлении мы решили держать ресурсы не отдельно, а сразу списком. Тогда во время миграции нам надо будет всего лишь заполнить список Resources значениями из уже устаревших полей Wood и Stone:
public class Migration_2_3 : IMigration { public Version ToVersion { get; } = new Version(0, 3, 0); public JObject Migrate(JObject data) { int woodAmount = data["Wood"]?.Value<int>() ?? 0; int stoneAmount = data["Stone"]?.Value<int>() ?? 0; JArray resources = data["Resources"] as JArray ?? new JArray(); resources.Add(JObject.FromObject(new { Name = "Wood", Amount = woodAmount })); resources.Add(JObject.FromObject(new { Name = "Stone", Amount = stoneAmount })); data["Resources"] = resources; data.Remove("Wood"); data.Remove("Stone"); return data; } }
Получается, чтобы уметь парсить данные из самой первой версии в последнюю, надо всего лишь написать такие строчки кода:
string json = File.ReadAllText(Path); GameData data = new Migrator(new Migration_1_2(), new Migration_2_3()) .Execute<GameData>(json);
Убедимся, что все работает
Без лишней верстки и запуска плеймода проверим работу миграций в инспекторе:
-
в Saved Data будет показываться сырой JSON, который лежит на устройстве
-
в Game Data будут парситься сырые данные, попутно проходя через миграции
Код GameDataEditor:
public class GameDataEditor : MonoBehaviour { [SerializeField, TextArea, ReadOnly] private string _savedData; [SerializeField] private GameData _gameData; private string Path => System.IO.Path.Combine(Application.streamingAssetsPath, "GameData.json"); [Button] private void Save() => File.WriteAllText(Path, JsonConvert.SerializeObject(_gameData)); [Button] private void Load() { string json = File.ReadAllText(Path); _gameData = new Migrator(new Migration_1_2(), new Migration_2_3()) .Execute<GameData>(json); } [Button] private void Show() { if (File.Exists(Path)) _savedData = File.ReadAllText(Path); } [Button] private void Clean() { _savedData = null; _gameData = null; } }
Для атрибутов Button и ReadOnly используется Odin Inspector, но вы можете использовать в качестве аналога Tri Inspector или просто встроенный атрибут ContextMenu (только последний так красиво не рисуется).
Введем рандомные данные в инспекторе, нажмем Save и Show. После этого мы должны увидеть также заполненное поле Saved Data:
Теперь перепрыгнем на ветку со следующей версией, очистим введенные данные и посмотрим как сработает миграция. При положительном результате мы увидем John и Doe в разных полях FirstName и SecondName соответственно:
Теперь переименуем файл сохранения, чтобы на него не «смотрел» GameDataEditor, и сохраним полученные данные. Таким образом мы сразу сможем проверить миграцию по последней версии с 1 и 2.
Сначала проверим миграцию с версии 2 на версию 3. Подход аналогичный: чистим инспектор и нажимаем Load:
Все работает. Теперь удаляем файл GameData и возвращаем оригинальное название файлу GameData old и делаем аналогичное действие:
Вуаля! Миграция с первой сразу на последнюю версию также работает.
Заключение
Оригинальный видос не плохой, просто такой подход, по моему скромному мнению, гораздо удобнее (хотя и принуждает пользоваться не только бедным JsonUtility).
Для желающих, все решение оформлено в отдельный репозиторий. Там же добавлены дополнительные проверки и собрана первая версия пакета для тех, что захочет применить это на своем проекте (хоть там всего под сотню строк кода).
Ссылки и литература
FastMigrations.Json.Net: https://github.com/vangogih/FastMigrations.Json.Net
JObject:
ссылка на оригинал статьи https://habr.com/ru/articles/902624/
Добавить комментарий