Версия для печати
Нажмите сюда для просмотра этой темы в оригинальном формате
Форум на Исходниках.RU > Общие вопросы > Сериализация объектов


Автор: --Ins-- 09.11.12, 23:02
Ну что ж, решил поделиться с вами своими наработками в области сериализации в Delphi. Данная тема всегда вызывала у меня интерес и со временем мой взгляд на то, как это должно быть, эволюционировал и в итоге пришел к тому, что будет продемонстировано ниже. Хочу заметить, что методика, используемая мной, чем-то напоминает .NET-овскую сериализацию, правда менее наворочена, однако была разработана независимо и любые совпадения - случайность :) Так же замечу, что приведенная реализация в версиях Delphi 2010 и старше может быть с легкостью модернизирована до такой степени, что код сериализуемых объектов в принципе будет переносим на платформу .NET - для этого предлагается научить сериализатор работать с атрибутами классов и их членов (SerializableAttribute, NonSerializedAttribute и т.д.).

Определения
Что же такое сериализация? Сериализация - процесс перевода какой-либо структуры данных (например объекта) в последовательность байтов. Обратный процесс восстановления структуры данных из последовательности байт называется десериализация. Сериализация используется в тех случаях, когда нужно сохранить объект в файл, память, базу данных, или передать по сети, с возможностью в дальнейшем восстановить объект в исходном состоянии.

Особенности
Итак, прежде чем приступить к рассмотрению, краткий обзор особенностей данной реализации:
  • Любой объект может быть сериализован. Для этого класс должен предоставить информацию о сериализуемых параметрах, а саму сериализацию на основе предоставленной информации выполняет специальный объект - сериализатор.
  • Предоставление информации для сериализатора возможно двумя путями. Первый - это объявить published-свойства. Такое объявление приведет к записи значения этого свойства при сериализации. Свойство может иметь объектных тип, в этом случае для записи этого свойства-объекта так же будет применен сериализатор, что позволяет использовать сериализатор для записи не только одиночных объектов, но и графов объектов. Второй способ применяется в случае, когда записываемые параметры нельзя или нецелесообразно предоставлять в виде published-свойств. В этом случае объект может реализовать специальный интерфейс настраиваемой сериализации, который позволяет записывать и читать произвольные дополнительные данные.
  • Отсутствует привязка к конкретному формату хранения данных. Объект только лишь объявляет, какие параметры необходимо сериализовать, но не уточняет в каком виде. Вопрос о конечном формате данных зависит от типа используемого сериализатора. Если был использован сериализатор XML, то данные будут записаны в XML-виде, если бинарный сериализатор - то в бинарном виде. Один и тот же объект можно записать в файлы разных форматов просто применив к нему разные типы сериализаторов
  • Существует механизм уведомлений объектов о процессе сериализации/десериализации. Данный механизм может быть использован для осуществления каких-либо действий над объектом до или после сериализации/десериализации
  • Реализован механизм управления ревизиями (версиями). Если структура данных объекта претерпела изменения в процессе развития программы, система позволит объектам читать файлы старых форматов, и наоборот - записывать данные в старых форматах в целях совместимости.

Итак, перейдем к рассмотрению реализации.
Дерево состояния объекта
Сериализация в предоставляемой библиотеке реализована в два этапа, с использованием промежуточного дерева состояния между объектом и потоком данных. Т.е. при записи параметры объекта сначала записываются в промежуточное дерево и лишь потом - в файл или поток, а при чтении - сначала из файла или потока формируется дерево состояния, а лишь потом по нему восстанавливается объект. Такое разделение процессов сериализации/десериализации позволяет:
  • Отвязаться от конкретного формата данных используя как промежуточную стадию одинаковое для всех форматов дерево
  • Реализовать в базовом классе сериализаторов операции по формированию дерева из состояния объекта и наоборот - восстановлению объекта из дерева, а операции записи дерева в поток и чтения дерева из потока переложить на потомков
  • Использовать запись в дерево и чтение из дерева вообще не используя никакие конкретные форматы данных - удобно если не нужно длительно хранить состояние объекта, а скажем скопировать или клонировать объект путем записи одним объектом своего состояния в дерево, а вторым - чтения из этого дерева. В данных операциях трата времени и ресурсов на формирование данных опеределенного типа (XML и т.д.) совершенно излишне.

Дерево состояния объекта представлено интерфейсом ITreeNode и классом TTreeNode, этот интерфейс реализующий. Вот объявление ITreeNode:
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
      // Базовый интерфейс узла дерева
      ITreeNode = interface(IUnknown)
      ['{9CD610C0-9F03-405B-B655-86B49E582DD3}']
        // Имя узла
        function GetName: WideString; safecall;
        procedure SetName(const Value: WideString); safecall;
        property Name: WideString read GetName write SetName;
        // Родитель
        function GetParent: ITreeNode; safecall;
        property Parent: ITreeNode read GetParent;
        // Список дочерних подузлов
        function GetNodesCount: Integer; safecall;
        function GetNode(Index: Integer): ITreeNode; safecall;
        function GetNodeByName(AName: WideString): ITreeNode; safecall;
        function GetNodeIndex(AName: WideString): Integer; safecall;
        property NodesCount: Integer read GetNodesCount;
        property Nodes[Index: Integer]: ITreeNode read GetNode;
        property NodeByName[AName: WideString]: ITreeNode read GetNodeByName;
        property NodeIndex[AName: WideString]: Integer read GetNodeIndex;
        // Список параметров узла
        function GetParamsCount: Integer; safecall;
        function GetParamName(Index: Integer): WideString; safecall;
        function GetParamValue(Index: Integer): OleVariant; safecall;
        procedure SetParamValue(Index: Integer; const Value: OleVariant); safecall;
        function GetParamValueByName(AName: WideString): OleVariant; safecall;
        procedure SetParamValueByName(AName: WideString; const Value: OleVariant); safecall;
        function GetParamIndex(AName: WideString): Integer; safecall;
        property ParamsCount: Integer read GetParamsCount;
        property ParamName[Index: Integer]: WideString read GetParamName;
        property ParamValue[Index: Integer]: OleVariant read GetParamValue write
          SetParamValue;
        property ParamValueByName[AName: WideString]: OleVariant read GetParamValueByName
          write SetParamValueByName;
        property ParamIndex[AName: WideString]: Integer read GetParamIndex;
        // Управление узлами
        function AddNode(AName: WideString): ITreeNode; safecall;
        function InsertNode(Index: Integer; AName: WideString): ITreeNode; safecall;
        procedure DeleteNode(Index: Integer); overload; safecall;
        procedure DeleteNode(AName: WideString); overload; safecall;
        procedure DeleteNode(Node: ITreeNode); overload; safecall;
        procedure ClearNodes; safecall;
        function NodeExists(AName: WideString): Boolean; safecall;
        procedure Assign(const Node: ITreeNode); safecall;
        procedure Merge(const Node: ITreeNode); safecall;
        // Управление параметрами
        procedure AddParam(AName: WideString; const AValue: OleVariant); safecall;
        procedure InsertParam(Index: Integer; AName: WideString; const
          AValue: OleVariant); safecall;
        procedure DeleteParam(Index: Integer); overload; safecall;
        procedure DeleteParam(AName: WideString); overload; safecall;
        procedure ClearParams; safecall;
        function ParamExists(AName: WideString): Boolean; safecall;
        // Полная очистка
        procedure Clear; safecall;
      end;

Объявление выглядит громоздким, но ничего сложного в этом типе нет. Любой узел дерева может содержать дочерние узлы (Nodes) и параметры (Params). Узлы и параметры имеют строковое имя, параметры имеют кроме имени вариантное значение. Методы интерфейса - это методы управления узлами и параметрами. Типы WideString и OleVariant использованы для совместимости интерфейса с COM/OLE.
При сериализации, данные объекта содержатся в узлах и параметрах дерева.

Класс сериализатора
Как уже было сказано выше, чтобы записать или прочитать состояние объекта из файла или потока, к нему нужно применить методы сериализатора. Сериализатор - это класс, который содержит методы записи/чтения состояния объекта. Базовым для всех типов сериализаторов является класс TSerializer. Вот его объявление:
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
      // Базовый класс сериализатора объектов
      TSerializer = class(TObject)
      public
        class procedure SaveToNode(AObject: TObject; const Node: ITreeNode;
          PreferStr: Boolean = True; Revision: Integer = -1);
        class procedure LoadFromNode(AObject: TObject; const Node: ITreeNode);
        class procedure SaveToStream(const Node: ITreeNode; Stream:
          TStream); overload; virtual; abstract;
        class procedure LoadFromStream(const Node: ITreeNode;
          Stream: TStream); overload; virtual; abstract;
        class procedure SaveToStream(AObject: TObject; Stream: TStream;
          PreferStr: Boolean = True; Revision: Integer = -1); overload;
        class procedure LoadFromStream(AObject: TObject; Stream: TStream); overload;
        class procedure SaveToFile(const Node: ITreeNode; FileName: String); overload;
        class procedure LoadFromFile(const Node: ITreeNode;
          FileName: String); overload;
        class procedure SaveToFile(AObject: TObject; FileName: String;
          PreferStr: Boolean = True; Revision: Integer = -1); overload;
        class procedure LoadFromFile(AObject: TObject; FileName: String); overload;
      end;

Методы класса можно разделить на группы.
  • Методы записи. Начинаются с SaveTo
  • Методы чтения. Начинаются с LoadFrom
По типу хранилища:
  • Работающие с файлом. Заканчиваются на File. Вызывают методы работы со Stream, передавая параметром TFileStream
  • Работающие с потоком. Заканчиваются на Stream.
  • Работающие с деревом состояния. Заканчиваются на Node.
По типу аргумента:
  • Работающие с объектом. Первый параметр типа TObject. При реализации формируют дерево и вызывают методы, работающие с ITreeNode
  • Работающие с деревом состояния. Первый параметр типа ITreeNode
Только два метода этого класса абстрактные - это методы, формирующие данные в потоке (TStream) по дереву и наоборот - дерево по данным из потока, т.е. методы отвечающие за формат хранения данных. Все остальные методы реализованы в классе TSerializer
Потомки класса перекрывают эти методы, обеспечивая хранение данных в соответствии с заданным форматом. Реализованы два потомка - для бинарного формата и для XML.

Сериализация объекта
Для примера сериализации рассмотрим класс, содержащий настройки программы.

<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
    // Класс, содержащий настройки приложения
    TApplicationSettings = class(TPersistent)
    ...
    published
      property ShowSplash: Boolean read FShowSplash write FShowSplash;
      property SplashTimeout: Integer read FSplashTimeout write FSplashTimeout;
      property UserProfile: String read FUserProfile write FUserProfile;
    end;


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

<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
    var
      Settings: TApplicationSettings;
    begin
      Settings := TApplicationSettings.Create;
      try
        Settings.ShowSplash := True;
        Settings.SplashTimeout := 2000;
        Settings.UserProfile := 'user.dat';
        // Записываем состояние объекта в файл 'Settings.xml'
        TXmlSerializer.SaveToFile(Settings, 'Settings.xml');
      finally
        Settings.Free;
      end;
    end;


Записанный файл будет выглядеть так:

<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
    <?xml version="1.0" encoding="windows-1251"?>
    <TApplicationSettings>
      <ShowSplash>True</ShowSplash>
      <SplashTimeout>2000</SplashTimeout>
      <UserProfile>user.dat</UserProfile>
    </TApplicationSettings>


Сериализатор позволяет сохранять не только published-свойства простых типов, но и свойства объектных типов, в этом случае объектное свойство будет сериализовано во вложенный узел. Например, если свойства ShowSplash и SplashTimeout перенести в объектное свойство Splash: TSplashSettings, у которого Show и Timeout будут published-cвойствами, то результирующий XML будет таким:

<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
    <?xml version="1.0" encoding="windows-1251"?>
    <TApplicationSettings>
      <Splash>
        <Show>True</Show>
        <Timeout>2000</Timeout>
      </Splash>
      <UserProfile>user.dat</UserProfile>
    </TApplicationSettings>


Директива stored так же позволяет управлять сериализуемостью свойства.

Настраиваемая сериализация
Как уже было сказано выше, не всегда возможно или целесообразно предоставлять сериализуемые данные в виде published-свойств. В этом случае класс может реализовать интерфейс ICustomSerialize. Вот его объявление:
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
      // Интерфейс настраиваемой сериализации
      ICustomSerialize = interface(IInterface)
      ['{BB7DC15E-3D67-4183-B758-F3CDF493A513}']
        procedure CustomSerialize(const Node: ITreeNode; Revision: Integer);
        procedure CustomDeSerialize(const Node: ITreeNode);
      end;


Для примера уберем свойства ShowSplash и SplashTimeout из секции published, и сериализуем их, используя названные механизмы.

<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
    // Класс, содержащий настройки приложения
    TApplicationSettings = class(TInterfacedPersistent,
      ICustomSerialize)
    ...
    protected
      // ICustomSerialize
      procedure CustomSerialize(const Node: ITreeNode; Revision: Integer);
      procedure CustomDeserialize(const Node: ITreeNode);
    public
      property ShowSplash: Boolean read FShowSplash write FShowSplash;
      property SplashTimeout: Integer read FSplashTimeout write FSplashTimeout;
    published
      property UserProfile: String read FUserProfile write FUserProfile;
    end;
     
    implementation
      
    procedure TApplicationSettings.CustomSerialize(const Node: ITreeNode;
      Revision: Integer);
    var
      SplashNode: ITreeNode;
    begin
      SplashNode := Node.AddNode('Splash');
      SplashNode.AddParam('Show', ShowSplash);
      SplashNode.AddParam('Timeout', SplashTimeout);
    end;
     
    procedure TApplicationSettings.CustomDeserialize(const Node: ITreeNode);
    var
      SplashNode: ITreeNode;
    begin
      SplashNode := Node.NodesByName['Splash'];
      ShowSplash := SplashNode.ParamValueByName['Show'];
      SplashTimeout := SplashNode.ParamValueByName['Timeout'];
    end;


Результат будет такой же, как и в предыдущем примере.

Сериализация массивов, списков и коллекций
Интерфейс настраиваемой сериализации наиболее часто нужен для реализации коллекций или свойств-массивов. Можно даже специально реализовать класс, инкапсулирующий список или массив, реализующий ICustomSerialize, и потом просто объявлять его как published для записи коллекции как составной части объекта. Реализация такого класса не представляет особой сложности - можно рассматривать его в качестве домашнего задания :)

Уведомления о сериализации
Любой сериализуемый объект может реализовать интерфейс ISerializeNotify.
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
      // Интерфейс уведомления о сериалицации
      ISerializeNotify = interface(IInterface)
      ['{9ED646CF-1C5E-48B2-A5C5-3191A8758BDB}']
        procedure BeginSave;
        procedure EndSave;
        procedure BeginLoad;
        procedure EndLoad;
      end;

Реализация этого интерфейса приведет к тому, что его методы будут вызываться при сериализации/десериализации (до и после), что позволит объекту обработать эти события, если это необходимо.

Контроль версий объектов
Последнее что осталось рассмотреть - это контроль версий. Бывают случаи, когда программа используется долго и структура объектов меняется. Тем не менее, хочется чтобы новые программы успешно читали файлы старых форматов. Для решения этой задачи предлагается следующий механизм. Сразу же хочу заметить, что если изменение в структуре объекта - это просто появление нового свойства, то вероятнее всего прибегать к этому механизму не придется. Если в дереве состояния не найдется записи о новом свойстве, то ошибки времени выполнения не возникнет, а значение свойства останется прежним, каким было до десериализации. Но если же структура объекта меняется значительно, то избежать использования данных механизмов проблематично.
Итак, чтобы реализовать в сериализуемом объекте поддержку контроля версий, необходимо выполнить следующие шаги:
  • Реализовать в объекте интерфейс IRevision.
  • Создать контроллер ревизии (потомок класса TRevisionController) для каждой версии объекта
  • Зарегистрировать контроллер в менеджере ревизий (TRevisionsManager)
Интерфейс IRevision:
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
      // Интерфейс управления ревизиями
      IRevision = interface(IInterface)
      ['{8370E426-0A0E-4B69-B180-7A8E5067A59F}']
        function GetRevision: Integer;
        property Revision: Integer read GetRevision;
        procedure WriteRevision(const Node: ITreeNode; Revision: Integer);
        function ReadRevision(const Node: ITreeNode): Integer;
      end;

Довольно простой интерфейс, содержит всего три метода: GetRevision должен вернуть номер текущей ревизии объекта. Метод WriteRevision - записывает ревизию в дерево состояния (например можно записать в параметр "Revision"). Метод ReadRevision - читает номер ревизии объекта из дерева. В последнем методе можно предусмотреть случай, когда номер ревизии не записан в дерево, т.е. файл был сохранен когда разных ревизий еще не было, и возвращать нулевой номер ревизии.
Номер ревизии можно указать в Save-методах сериализатора - это приведет к тому, что объект будет записан в соответствии с указанной ревизией.
Второй этап, который необходимо реализовать - это создать контроллер ревизии. Класс контроллера объявлен так:
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
      // Базовый класс контроллеров ревизии
      TRevisionController = class(TObject)
      protected
        class procedure Upgrade(const Node: ITreeNode; AObject:
          TObject); virtual; abstract;
        class procedure Downgrade(const Node: ITreeNode; AObject:
          TObject); virtual; abstract;
        class function GetNestedObjectRevision(AObject: TObject; Revision: Integer):
          Integer; virtual;
      public
        class function Revision: Integer; virtual; abstract;
        class function PrevRevision: Integer; virtual;
        class function NextRevision: Integer; virtual;
        class function ObjectClass: TClass; virtual; abstract;
        class function Critical: Boolean; virtual;
        class function RevisionName: String; virtual;
      end;

Рассмотрим методы этого класса:
  • Revision - возвращает номер ревизии, для которой реализован контроллер. Должен быть перекрыт
  • PrevRevision - номер предыдущей ревизии. В базовой реализации возвращает Revision - 1
  • NextRevision - номер следующей ревизии. В базовой реализации возвращает Revision + 1
  • ObjectClass - возвращает класс объекта, для которого реализован контроль ревизий (класс объекта, который изменил свою структуру). Должен быть перекрыт
  • Critical - описательный метод, с его помощью можно указать что изменения критические, т.е. не поддерживаются предыдущие версии. В базовой реализации возвращает False
  • RevisionName - описательный метод, позволяет указать текстовое описание ревизии (можно использовать в окнах диалога сохранения)
  • Upgrade - один из двух важнейших методов. В своей реализации должен преобразовать дерево, записанное в формате предыдущей ревизии, в дерево, записанное в соответствии с форматом текущей ревизии. Т.е. добавить в дерево новые параметры или изменить структуру записи старых в соответствии с новым форматом. Используется менеджером ревизий при чтении файлов старых форматов. Должен быть перекрыт.
  • Downgrade - один из двух важнейших методов. В своей реализации должен преобразовать дерево, записанное в соответствии с форматом текущей ревизии, в дерево, записанное в формате предыдущей ревизии. Используется менеджером ревизий при записи файлов старых форматов. Должен быть перекрыт.
  • GetNestedObjectRevision - опциональный метод, в случае если записываемый объект содержит вложенные объекты, позволяет указать номер ревизии для них.

Ну и когда класс контроллера ревизии написан, его необходимо зарегистрировать в менеджере ревизий:
<{CODE_COLLAPSE_OFF}><{CODE_WRAP_OFF}>
    var
      Mgr: TRevisionsManager;
    begin
      Mgr := TRevisionsManager.GetInstance;
      Mgs.RegisterController(MyRevisionControllerClass);


Ну вот собственно и все. Файлы исходных кодов классов прилагаются, замечания/предложения приветствуются
Serialization.rar (, : 19)

Powered by Invision Power Board (https://www.invisionboard.com)
© Invision Power Services (https://www.invisionpower.com)