Версионность сериализованных данных

Сложность 100-200

Во время изучения событийных систем и CQRS, поднимается вопрос о том, что надо каким-то образом читать и преобразовывать старые данные (как правило сериализованные в xml или json) к текущему состоянию системы, которая скорее всего изменилась со времени запуска. На самом деле проблема восстановления данных, чаще всего каких-либо настроек, весьма остро порой встает перед разработчиками. Т.е. у нас есть некоторое приложение, которое было уже доставлено конечному пользователю, и нам надо изменить формат сохранения настроек, сообщений. Если смотреть на проблему очень поверхностно, то обычно читаются настройки в старом формате, руками преобразуются в новый формат и сохраняются уже в новом виде.

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

На написание статьи и кода меня сподвигло наличие свободного времени (в меньшей части) и тот ужас, что я увидел в проекте коллег (в большей части). Или, если выразиться политкорректно то, код был чрезмерно раздут для задачи использовался не оптимально. Я думаю, что лучше один раз увидеть, чем сто раз прочитать.

На примере ниже сохраняются и восстанавливаются настройки расположения клиентских настроек для MDI приложения. Именно от того, что надо работать с любой версией настроек и было применено ручное составление и разбор XML файла. В доказательство можно привести проверку на внутреннюю версию файла. Хотя… что-то я смотрю и вижу, что старые версии не восстанавливаются, йехъ.

01

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

Идея и описание работы

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

Потратив некоторое время над этим вопросом, я пришел к следующему виду объявления версий:

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

У каждого класса, который поддерживает версионность, следует указать все типы возможных «родителей», классов от которых можно прийти к классу, к которому применяются атрибуты.

В качестве одной из альтернатив такому объявлению, я рассматривал возможность итеративного указания версий, т.е. указывать только версию с которой можно дойти до нужного класса. Через некоторое время и рассматриваемые случаи мне идея разонравилась, так как это чрезмерно бы усложнило процесс нахождения и определения всех возможных родителей. Так же это будет неудобно с точки зрения чтения кода, так как пришлось бы просматривать и искать вручную версии, вместо того, чтобы увидеть их сразу в одном месте. Опыт подсказывает, что такое указание «родителей» предпочтительнее. Порядок «родителей» так же указывается в атрибуте. Если вы зададите одинаковый порядок для миграций, то сами виноваты.

Кстати, я думаю вы заметили, что никаких дополнительных интерфейсов и наследований, методов не нужно, если поля только добавляются или же удаляются, а имена сохраняют свои имена.

Если же имена поменялись, или надо сделать сложное ручное преобразование, то следует реализовать интерфейс IManualMigration:

При такой реализации, ручное преобразование будет применено к последнему указанному способу миграции. Т.е. в данном случае к преобразованию данных из класса ClassV2 к классу ClassV3.

В виду особенностей реализации кода, метод CreateFrom() принимает и возвращает object, но думаю это не будет большой проблемой в реальности.

Комбинируя и реализовывая таким образом классы со старыми версиями, получаем весьма удобный и компактный способ указания цепочки преобразований. Т.е. итоговое использование выглядит примерно следующим образом:

Обратите внимание, что сериализуем в json и обратно разные типы объектов и все это работает.

Реализация

Реализация такого функционала, на мой взгляд, получилась компактной и простой. Основные моменты реализации заключаются в следующем:

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

Вот так, в целом, логически не сложно все происходит.

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

В блоке try{} происходит стандартная обработка, т.е. работает с актуальной версией данных и класса в который преобразовываются данные. Тут же вы видите, что метод ReadObject(), о котором я говорил раньше, действительно не причиняет проблем с приведением типов, пользователь не замечает этого.

В блоке catch{} начинается самое интересное, т.е. получаем список классов, от которых можем дойти до интересующего нас (GetEvolutionListFor()), далее определяем с какого класса можем восстановить данные (LocateNearestCompatibleVersion()), и последним циклом восстанавливаем данные либо в автоматическом режиме с помощью AutoMapper, либо вызываем метод ручного маппинга. При ручном маппинге приходится создавать пустой экземпляр класса с помощью активатора, так что некоторым ограничением, обязательным условием – является наличие конструктора без параметров.

Остальные методы на мой взгляд не представляют особенного интереса, так как там по факту идет перебор атрибутов, сортировка их в соответствии с полем Order или же определение «ручного» сопоставления. Эти методы можно посмотреть в исходном коде.

К слову, при реализации задуманного функционала мне не понадобились сторонние инструменты работы с атрибутами, т.е. любимый PostSharp. Публичный контракт выглядит очень минималистично:

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

Итого

На мой взгляд, получилась весьма компактная по коду и наглядная по применению библиотека, которая позволит снять проблему версионности данных как минимум для POCO объектов, в которых как правило и идет сохранение настроек или же сообщений для EventStorage.

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

Буду рад конструктивным комментариям и прочим дополнениям по существу вопроса.

 

 

Hard’n’Heavy!

 

2 комментарий на “Версионность сериализованных данных

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

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

    Т.е. каждая форма добавляла какие-то свои настройки и умела читать\сохранять их в обычный xml.

    А предлагаемое тобой решение немного сомнительно.

    • Рассмотрим пример на основе твоего. Вот есть у нас самая простая форма, которая умеет сохранять свое положение, пусть это будет 4 значения (top, left, width, height). В таком виде программа поставлена уже многим пользователям и они работают с ней. В какой-то момент мы решаем, что этих свойств недостаточно и в качестве параметра положения начинает выступать состояние окна (minimized, normal, maximaized). Это надо добавить в файл настроек и уметь читать и писать ее. Если в вашей программе идет десериализация по простому пути, т.е. мы говорим, что вот есть данные и десериализуй их в класс, то у нас случится облом, так как данных недостаточно для десериализации и старые настройки нельзя будет прочитать. Чтобы решить эту проблему малой кровью и предлагается мое решение.

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

      Если каждая форма у вас создает свой файл настроек, то мое решение должно подходить тоже хорошо.

      Или я чего-то недопонял в примере?

Оставить комментарий