Конфигурирование .NET-приложений

Хранение и редактирование настроек

Автор: Андрей Корявченко
The RSDN Group

Источник: RSDN Magazine #3
Опубликовано: 12.05.2003
Версия текста: 1.0
Введение
Теория
Загрузка настроек
Атрибуты, управляющие сериализацией XmlSerializer
Сохранение настроек
Графический интерфейс
Изменение названий свойств в PropertyGrid
Реакция на изменение настроек
Сериализация произвольных вложенных типов
Заключение

Введение

Не секрет, что практически каждое приложение требует каких-то настроек. Данная статья рассказывает об одном из возможных способов реализации механизма их хранения и редактирования. Исходные коды взяты из реального приложения, RSDN@Home, оффлайн-клиента для форумов www.rsdn.ru. Полные исходные коды можно получить на CVS, правила доступа к ним – в форуме по проекту на www.rsdn.ru.

Теория

Первое, с чем нам надо определиться – это способ хранения настроек. Наиболее распространенные места для этого: реестр Windows, база данных, двоичный файл, просто текстовый файл, ini-файл, XML-файл. Реестр очень удобен для хранения, однако перенос настроек с одной машины на другую затруднен, а при переустановке ОС такие настройки могут быть потеряны. База данных используется далеко не в каждом приложении, поэтому рекомендовать в качестве универсального такое хранилище нельзя. Двоичные файлы очень хорошо подходят для хранения больших объемов данных. Однако настройки, как правило, довольно невелики, а вот то, что в случае двоичного файла обязательно требуется специализированный редактор, и затруднен переход от версии к версии, делает его не очень удобным способом хранения. Текстовые файлы могут хранить что угодно и как угодно, однако для таких настроек необходим собственный парсер. Ini-файл удобен, однако в нем тяжело хранить списки и иерархически организованные данные. XML-файл свободен от перечисленных недостатков, поэтому именно он был выбран в качестве хранилища.

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

Хорошим тоном считается наличие графического интерфейса для конфигурирования приложения. В рассматриваемом примере такой интерфейс также строится автоматически.

Загрузка настроек

Для сохранения экземпляров классов в XML .NET Framework предлагает два способа. Первый, SoapFormatter, умеет сохранять все сериализуемые объекты, однако получаемый результат малочитаем. Если это не важно, то можно использовать его, или даже BinaryFormatter, генерирующий двоичные данные. В нашем случае читаемость была критична, поэтому был использован другой способ – XmlSerializer. Этот сериализатор имеет ограничения по сериализуемым данным, однако выдает вполне читаемый XML, и довольно толерантен как к убиранию части настроек, так и к добавлению неизвестной информации.

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

ПРЕДУПРЕЖДЕНИЕ

Будьте внимательны при хранении массивов. Скажем, массив целых сериализуется примерно в такую структуру:

<ia>

<int>0</int>

..

<int>0</int>

</ia>

Если массив большой, то файл раздуется, и будет плохо читаем. Однако существует особый случай, массив byte[] упаковывается base64. Результат при этом примерно такой:

<dockingLayout>//48AD8AeABtAGwAIAB2AGUAcgBzAGkAbwBuAD0AIgAxAC4AMAAiACAAZQBuAGMAbwBkAGkAbgBn

AD0AIgB1AHQAZgAtADEANgAiAD8APgANAAoAPAAhAC0ALQAgAE0AYQBnAGkAYwAsACAAVABoAGUA

IABVAHMAZQByACAASQBuAHQAZQByAGYAYQBjAGUAIABsAGkAYgByAGEAcgB5ACAAZgBvAHIAIAAu

</dockingLayout>

Для запрета сериализации каких-либо полей их можно пометить атрибутом [XmlIgnore].

ПРИМЕЧАНИЕ

Существует много других атрибутов, управляющих сериализацией. О них – в следующей части статьи.

Так как настройки едины для всей программы, и создание нескольких экземпляров этого класса может привести к непредсказуемым последствиям, то для реализации этого класса выберем паттерн синглтон.

      public
      class Config
{
  privatestaticobject lockFlag = newobject();
  privatestatic Config instance;
  [XmlIgnore]
  publicstatic Config Instance
  {
    get
    {
      lock(lockFlag) 
      {
        if(instance == null)
        {     
          try
          {
            //Пытаемся загрузить файл с диска и десериализовать егоusing(FileStream fs = 
                new FileStream(LocalUser.GetDatabasePath()
                + "\\config.xml",FileMode.Open))
            {
              System.Xml.Serialization.XmlSerializer xs = 
                  new System.Xml.Serialization.XmlSerializer(typeof(Config));
              instance = (Config)xs.Deserialize(fs);
            }
          }
          catch(Exception e)
          {
            //Если не удалось десериализовать то просто создаем новый экземпляр
            instance = new Config();
          }
        }
      }
      return instance;
    }
  }
}

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

ПРИМЕЧАНИЕ

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

Для перезагрузки настроек достаточно обнулить поле instance. При следующем обращении настройки будут заново загружены с диска.

      public
      static
      void Reload()
{
  instance = null;
}

Атрибуты, управляющие сериализацией XmlSerializer

Для управления сериализацией существует целый набор атрибутов. Самые интересные из них вкратце опишу.

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

Атрибут Тип поля Действие
XmlAnyAttributeAttribute Массив XmlAttribute Заносит в поле массив неизвестных атрибутов
XmlAnyElementAttribute Массив XmlElement То же, но тегов

Следующий класс атрибутов – атрибуты, управляющие сериализацией массивов. Атрибут XmlArrayAttribute указывает сериализатору сериализовать поле, свойство или возвращаемое значение как XML-массив. Обычные массивы сериализуются подобным образом и без указания. Однако можно указать этот атрибут и для классов, реализующих интерфейс IEnumerable, например ArrayList. Кроме того, этот атрибут позволяет задать имя тега элемента и допустимость пустых значений.

Для более тонкого управления сериализацией массивов предназначен элемент XmlArrayItemAttribute. Этот атрибут позволяет задать для каждого типа, наследника типа массива (для IEnumerable это object) свои параметры, такие, как имя тега элемента массива.

Следующий набор атрибутов позволяет задать конкретный вариант способа сериализации.

Атрибут Тип поля Действие
XmlAttributeAttribute Поле, свойство, параметр или возвращаемое значение Указывает, что помеченный член нужно сериализовать как атрибут
XmlElementAttribute То же То же, но как тег
XmlTextAttribute То же То же, но как текст

Атрибут XmlEnumAttribute позволяет переопределить строки, использующиеся для идентификации элементов перечисления.

Атрибут XmlIgnoreAttribute уже упоминался, он говорит сериализатору о необходимости игнорировать помеченный член.

XmlRootAttribute позволяет задать имя и namespace для корневого тега сериализованного документа.

Более подробное описание атрибутов можно найти в документации, поставляемой в комплекте .NET Framework SDK.

ms-help://MS.NETFrameworkSDK/cpguidenf/html/cpconattributesthatcontrolserialization.htm

Сохранение настроек

Для сохранения настроек просто сериализуем класс:

      public
      void Save()
{
  using(FileStream fs = 
    new FileStream(LocalUser.GetDatabasePath()+"\\config.xml",FileMode.Create))
  {
    System.Xml.Serialization.XmlSerializer xs = 
        new System.Xml.Serialization.XmlSerializer(typeof(Config));
    xs.Serialize(fs,instance);
  }
}
СОВЕТ

Можно немножко ускорить работу, храня единожды созданный экземпляр в static-поле класса. Однако такой вариант не потокобезопасен.

Все, теперь класс готов для хранения настроек. Для добавления новой настройки просто объявляется публичное поле или свойство нужного типа. Все остальное будет работать автоматически.

Не всегда можно сериализовать классы из фреймворка. К примеру, класс System.Drawing.Rectangle, помимо свойств X, Y, Width, Height, для удобства пользования имеет также свойства Location, Size и т.д. Эти свойства просто вычисляются, однако сериализатор об этом не знает и честно их запихивает в XML. Для таких классов необходимо создавать их близнецы с указанием, что именно следует сериализовать.

      public
      struct FormBounds
{
  publicint X;
  publicint Y;
  publicint Width;
  publicint Height;
  public FormBounds(int x,int y,int w,int h)
  {
    X = x;
    Y = y;
    Width = w;
    Height = h;
  }
  [XmlIgnore]
  public Rectangle Bounds
  {
    get
    {
      returnnew Rectangle(X,Y,Width,Height);
    }
    set
    {
      X = value.X;
      Y = value.Y;
      Width = value.Width;
      Height = value.Height;
    }
  }
}
Не поддаются сериализации и сложные классы, например хеш-таблицы. В этом случае их нужно преобразовывать к массивам.
[XmlIgnore]
public Hashtable lastReadMessage = new Hashtable();
//Для сериализацииpublicstruct PositionEntry
{
  publicint forumId;
  publicint msgId;
}
[Browsable(false)]
public PositionEntry[] serLastReadMessage
{
  get
  {
    PositionEntry[] pea = new PositionEntry[lastReadMessage.Count];
    int i = 0;
    foreach(DictionaryEntry de in lastReadMessage)
    {
      pea[i].forumId = (int)de.Key;
      pea[i].msgId = (int)de.Value;
      i++;
    }
    return pea;
  }
  set
  {
    lastReadMessage.Clear();
    foreach(PositionEntry pe in value)
      lastReadMessage.Add(pe.forumId,pe.msgId);
  }
}

Атрибут [Browsable] указывает, что это свойство не подлежит редактированию в графическом интерфейсе.

Графический интерфейс

Для редактирования мы также воспользуемся стандартным решением, классом System.Windows.Forms.PropertyGrid. Этот тот самый грид, который мы видим в Visual Studio при отображении свойств.

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

      public
      static Config GetClone()
{
  return (Config)instance.MemberwiseClone();
}

publicstaticvoid NewConfig(Config cfg)
{
  instance = cfg;
}

Далее просто присваиваем клон свойству propertyGrid.SelectedObject:

propertyGrid.SelectedObject = Config.GetClone();

А при подтверждении изменений передадим результат:

Config.NewConfig((Config)propertyGrid.SelectedObject);

Теперь если мы запустим приложение, то увидим список наших настроек. Примерно такой:


Рисунок 1. Диалог конфигурации.

ПРИМЕЧАНИЕ

В отличие от сериализатора, PropertyGrid не показывает полей, только свойства. Таким образом, в полях можно хранить настройки, которые не нужно редактировать пользователю. Свойства также можно сделать невидимыми, пометив их атрибутом [Browsable(false)].

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

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

      private
      bool autoSync = false;
[Description("Автоматически запускать синхронизацию через указанный промежуток времени")]
[Category("Синхронизация")]
]
publicbool AutoSync
{
  get {return autoSync;}
  set {autoSync = value;}
}

А вот с последней дело обстоит несколько сложнее.

Изменение названий свойств в PropertyGrid

PropertyGrid получает информацию об объекте с помощью Reflection. Reflection предоставляет типу возможность генерировать информацию о себе во время исполнения. Это делается с помощью реализации интерфейса ICustomTypeDescriptor. Нас интересует, прежде всего, метод этого интерфейса GetProperties(), который возвращает PropertyDescriptorCollection, содержащий описание всех свойств. Элементы этой коллекции – это потомки класса PropertyDescriptor.

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

      using System;
using System.ComponentModel;

namespace RSDN.Janus
{
  publicclass PropDispNameWrapper : ICustomTypeDescriptor
  {
    // Оборачиваемый объект.privateobject _obj;
    // Коллекция, хранящая обертки над описаниями свойств.
    PropertyDescriptorCollection _propsCollection;

    // Позволяет получить обернутый объект.publicobject Unwrap{ get{ return _obj; } }

    public PropDispNameWrapper(object obj)
    {
      // Запоминаем оборачиваемый объект.
      _obj = obj;
      // Создаем новую (пустую) коллекцию описаний свойств, // в которую поместим обертки над реальными описаниями.
      _propsCollection = new PropertyDescriptorCollection(null);
      PropertyDescriptorCollection pdc = 
          TypeDescriptor.GetProperties(obj, true);
      // Перебираем описания свойств, создаем для каждого // из них обертку и помещаем ее в коллекцию.foreach(PropertyDescriptor pd in pdc)
        _propsCollection.Add(new MyPropDesc(pd));
    }

    //////////////////////////////////////////////////////////// ICustomTypeDescriptor///
    AttributeCollection ICustomTypeDescriptor.GetAttributes() 
    {
      returnnew  AttributeCollection(null);
    }

    string ICustomTypeDescriptor.GetClassName() 
    {
      returnnull;
    }

    string ICustomTypeDescriptor.GetComponentName() 
    {
      returnnull;
    }

    TypeConverter ICustomTypeDescriptor.GetConverter() 
    {
      returnnull;
    }

    EventDescriptor ICustomTypeDescriptor.GetDefaultEvent() 
    {
      returnnull;
    }


    PropertyDescriptor ICustomTypeDescriptor.GetDefaultProperty() 
    {
      returnnull;
    }

    object ICustomTypeDescriptor.GetEditor(Type editorBaseType) 
    {
      returnnull;
    }

    EventDescriptorCollection ICustomTypeDescriptor.GetEvents() 
    {
      returnnew EventDescriptorCollection(null);
    }

    EventDescriptorCollection ICustomTypeDescriptor.GetEvents
        (Attribute[] attributes) 
    {
      returnnew EventDescriptorCollection(null);
    }

    PropertyDescriptorCollection ICustomTypeDescriptor.GetProperties() 
    {
      return _propsCollection;
    }

    PropertyDescriptorCollection ICustomTypeDescriptor.GetProperties(
        Attribute[] attributes) 
    {
      return _propsCollection;
    }

    object ICustomTypeDescriptor.GetPropertyOwner(PropertyDescriptor pd) 
    {
      returnthis;
    }
  }

  publicclass MyPropDesc : PropertyDescriptor
  {

    PropertyDescriptor _PropDesc;

    public MyPropDesc(PropertyDescriptor PropDesc) : base(PropDesc)
    {
      _PropDesc = PropDesc;
    }
    publicoverridestring Category{ get{ return _PropDesc.Category; } }
  
    // Это свойство возвращает название свойства, // отображаемое в propertyGridpublicoverridestring DisplayName 
    { 
      get 
      {
        // Пытаемся получить атрибут DisplayNameAttribute.// В случае неудачи будет возвращен null.
        DisplayNameAttribute mna = 
            _PropDesc.Attributes[typeof(DisplayNameAttribute)] as 
            DisplayNameAttribute;
        if(mna != null)
          // Если имеется атрибут DisplayNameAttribute,// возвращаем текст, помещенный в него.return mna.ToString();
        // Если атрибут DisplayNameAttribute не задан,// возвращаем оригинальное имя свойства.return _PropDesc.Name;
      }
    }

    publicoverride Type ComponentType 
    {
      get 
      {
        return _PropDesc.ComponentType;
      }
    }

    publicoverridebool IsReadOnly 
    {
      get 
      {
        returnfalse;
      }
    }

    publicoverride Type PropertyType 
    {
      get 
      {
        return _PropDesc.PropertyType;
      }
    }

    publicoverridebool CanResetValue(object component) 
    {
      return _PropDesc.CanResetValue(((PropDispNameWrapper)component).Unwrap);
    }

    publicoverrideobject GetValue(object component) 
    {
      return _PropDesc.GetValue(((PropDispNameWrapper)component).Unwrap);
    }

    publicoverridevoid ResetValue(object component) 
    {
      _PropDesc.ResetValue(((PropDispNameWrapper)component).Unwrap);
    }

    publicoverridevoid SetValue(object component, object value) 
    {
      _PropDesc.SetValue(((PropDispNameWrapper)component).Unwrap, value);
    }

    publicoverridebool ShouldSerializeValue(object component) 
    {
      return _PropDesc.ShouldSerializeValue(
          ((PropDispNameWrapper)component).Unwrap);
    }
  }

  [AttributeUsage(AttributeTargets.Property |
     AttributeTargets.Field)]
  [Serializable]
  publicclass DisplayNameAttribute : Attribute
  {
    string _sText;
    public DisplayNameAttribute(string Text) : base()
    {
      _sText = Text;
    }
    publicoverridestring ToString()
    {
      return _sText;
    }
  }
}

Теперь достаточно пометить нужное свойство атрибутом DisplayName.

      private
      bool showSplash = true;
[DisplayName("Показывать заставку")]
[Description("Показывать заставку при старте приложения")]
[Category("Общие")]
publicbool ShowSplash
{
  get {return showSplash;}
  set {showSplash = value;}
}

Оборачиваем объект конфигурации для передачи его в PropertyGrid.

propertyGrid.SelectedObject = new PropDispNameWrapper(Config.GetClone());

После редактирования разворачиваем:

Config.NewConfig((Config)((PropDispNameWrapper)propertyGrid
    .SelectedObject).Unwrap);

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


Рисунок 2.

Реакция на изменение настроек

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

Для начала опишем набор возможных действий в виде перечисления.

      public
      enum ChangeAction
{
  NoAction = 0,
  Refresh = 1,
  Restart = 2
}

Размечать свойства будем атрибутом.

[AttributeUsage(AttributeTargets.Property)]
publicclass ChangePropertyAttribute : System.Attribute 
{
  private ChangeAction _action;

  public ChangePropertyAttribute( ChangeAction action)
  {
    _action = action;
  }

  publicvirtual ChangeAction Action
  {
    get { return _action; }      
  }
}

Нужные свойства помечаем этим атрибутом:

      private
      bool showFullForumNames = true;
[DisplayName("Полные имена форумов"),
  Description("Показывать полные наименования форумов"),
  Category("Форумы"),
  ChangeProperty( ChangeAction.Refresh)]
publicbool ShowFullForumNames
{
  get{return showFullForumNames;}
  set{showFullForumNames = value;}
}

Обрабатывать изменения будем в событии, генерируемом PropertyGrid при изменении значения PropertyValueGrid.

      private
      void PropertyValueChanged( object sender, PropertyValueChangedEventArgs e)
{
  Type t = typeof(ChangePropertyAttribute);
  ChangePropertyAttribute attr = (ChangePropertyAttribute)e.ChangedItem.PropertyDescriptor.Attributes[t];
  ChangeAction act = ChangeAction.NoAction;
  if (attr != null)
    act = attr.Action;
  StrongAction( act);
}

privatevoid StrongAction( ChangeAction action)
{
  _action |= action;
}

private ChangeAction _action;
public ChangeAction Action
{
  get { return _action;}
}

После редактирования в свойстве Action будет содержаться тип действия, которое необходимо произвести.

Эти подходы не являются взаимоисключающими и могут быть использованы совместно.

Сериализация произвольных вложенных типов

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

ПРИМЕЧАНИЕ

XmlSerializer знает обо всех базовых типах. Кроме того, он умеет сериализовать String, а также классы, реализующие интерфейс IEnumerable, класс StringBulder и, возможно, некоторые другие. Полного списка таких классов мне найти не удалось.

Особо следует отметить возможность сериализации XmlNode. Таким способом можно внедрять в выходной поток любой XML. Для этого XmlSerializer предоставляет несколько способов.

Для элементов массива реальный тип данных можно указать атрибутом XmlArrayItemAttribute:

      public
      class Group
{
   [XmlArrayItem(Type = typeof(Employee))]
   [XmlArrayItem(Type = typeof(Manager))]
   public Employee[] Employees;
}
publicclass Employee
{
   publicstring Name;
}
publicclass Manager : Employee
{
   publicint Level;
}

Выше я показывал, как можно хранить в классе конфигурации экземпляр Hashtable, преобразуя его к массиву. Для ArrayList в таком преобразовании нет необходимости. Для него (и для любого класса, реализующего IEnumerable) достаточно указать атрибутом XmlElement типы хранимых значений.

      public
      class Group
{
   [XmlElement(Type = typeof(Employee))] 
   [XmlElement(Type = typeof(Manager))]
   public ArrayList Info;
}

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

[XmlInclude(typeof(Car)), XmlInclude(typeof(Bike))]
public Vehicle Vehicle(string licenseNumber) 
{
  if (licenseNumber == "0")
  {
    Vehicle v = new Car();
    v.licenseNumber = licenseNumber;
    return v;
  }
  elseif (licenseNumber == "1")
  {
    Vehicle v = new Bike();
    v.licenseNumber = licenseNumber;
    return v;
  }
  elsereturnnull;
}

У всех этих способов есть один, но очень существенный недостаток – они позволяют указать только те типы, которые известны заранее. В ряде случаев это неприемлемо.

В качестве примера такой ситуации приведу механизм хранения конфигураций плагинов в RSDN@Home. Их конфигурация осуществляется способом, описанным в этой статье. Хотелось бы хранить их конфигурацию в общем файле, однако типы классов конфигураций плагинов не известны заранее.


Рисунок 3.

Для подобных ситуаций существует специальная форма конструктора XmlSerializer, где в качестве второго параметра передается список типов, которые могут ему попасться.

      private
      static XmlSerializer CreateSerializer()
{
  ArrayList res = new ArrayList();
  foreach(DictionaryEntry de in PluginManager.Instance.ConfigurablePlugins)
    res.Add(((IConfigurablePlugin)de.Value).Config.GetType());
  returnnew XmlSerializer(typeof(Config), (Type[])res.ToArray(typeof(Type)));
}

В результате XmlSerializer сериализует массив экземпляров объектов конфигураций плагинов.

В приведенном выше примере генерируется следующий XML:

...
<PluginConfigs>
  <PluginConfig>
    <PluginClass>RSDN.Janus.StdPlugs.CitationPlugin</PluginClass>
    <PluginConfiguration xsi:type="CitationPluginConfig">
      <lastPosition>0</lastPosition>
      <Citations>
        <string>Cit1</string>
        <string>Cit2</string>
        <string>Cit3</string>
      </Citations>
      <QueryType>Случайно</QueryType>
    </PluginConfiguration>
  </PluginConfig>
  <PluginConfig>
    <PluginClass>RSDN.Janus.StdPlugs.WinampPlugin</PluginClass>
    <PluginConfiguration xsi:type="WinampPluginConfiguration">
      <PlayerVersion>Winamp3x</PlayerVersion>
      <SilentName>silent</SilentName>
    </PluginConfiguration>
  </PluginConfig>
</PluginConfigs>
...

Заключение

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

Возможны и другие подходы. Например, сериализация особо сложных кусков SoapFormatter или BinaryFormatter. Для первого можно использовать XmlNode, для второго – преобразовывать двоичные данные в Base64.

Успехов в профессиональной деятельности!


Эта статья опубликована в журнале RSDN Magazine #3. Информацию о журнале можно найти здесь