Singleton serialization или сад камней

image

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

Но зачем городить огород сад?

Первый логичный вопрос: если так проблем то зачем это вообще нужно? Такая хитрость, действительно, требуется не часто. Хотя многие люди, только начинающие работу с WPF или WinForms, пытаются реализовать таким образом файл с настройками приложения. Пожалуйста, не тратьте свое время и не изобретайте велосипед: для этого есть Application и User Settings (почитать про это можно здесь и здесь). Вот примеры, когда сериализация может потребоваться:
Хочется передать синглтон по сети или между AppDomain. К примеру, клиент и сервер одновременно работают с одним и тем же ftp и синхронизируют свои сведения о нем. Информацию об ftp можно хранить в синглтоне (и там же пристроить методы для работы с ним).
Сериализуется класс, которой присваивается различным элементам, но значение должно быть одинаковым для всех. Примером такого класса может являться DBNull.
Синглтон

В качестве несложного примера возьмем такой синглтон:
public sealed class Settings : ISerializable
{
    private static readonly Settings Inst = new Settings();
    private Settings()
    {
    }

    public static Settings Instance
    {
        get { return Inst; }
    }


    public String ServerAddress
    {
        get { return _servAddr; }
        set { _servAddr = value; }
    }

    public String Port
    {
        get { return _port; }
        set { _port = value; }
    }
    private String _port = "";
}

Сразу сделаю несколько комментариев по коду:
  • Намерено отсутствуют ленивые вычисления, чтобы не усложнять код.
  • Свойства не могут быть сделаны автоматическими, т.к. имена скрытых полей класса генерируются снова при каждой компиляции, и однажды честно сериализованный объект может уже не десериализоваться по причине различия этих имен.

Первый взгляд

В простых случаях для сериализации в C# хватает добавить атрибут Serializable. Что ж, не будем сильно задумываться на сколько наш случай сложен и добавим этот атрибут. Теперь попробуем сериализовать наш синглтон в трех вариантах: SOAP, Binary и обычный XML.
Для примера сериализуем и десериализуем бинарно (остальные способы аналогичны):
using (var mem = new MemoryStream())
{
    var serializer = new BinaryFormatter();
    serializer.Serialize(mem, Settings.Instance);
    mem.Seek(0, SeekOrigin.Begin);
    object o = serializer.Deserialize(mem);
    Console.WriteLine((Settings)o == Settings.Instance);
}

(Не)ожиданно на консоль будет выведено false, а это значит, что мы получили два объекта-синглтона. Такой результат можно предвидеть, если вспомнить, что в процессе десериализации с помощью рефлексии вызывается приватный конструктор и все десериализуемые значения присваиваются новому объекту. UPD: Как справедливо заметил kekekeks будет вызван не приватный конструктор, а
BinaryFormatter использует FormatterServices.GetSafeUninitializedObject, который позволяет создать экземпляр объекта без вызова конструктора.
Именно эта особенность синглтона кладет первый камень в наш сад: синглтон перестает быть синглтоном.

Усложняем и… кладем еще каменей.

Так как не получилось сделать все просто, придется усложнить Если обратимся к более “ручному” процессу сериализации через интерфейс ISerializable, то на первый взгляд выгоды кажется никакой: прошлая беда не исчезла, а сложность возросла. Поэтому для дальнейшей действий нам еще потребуется достаточно редко используемый интерфейс IObjectReference. Все что он делает: показывает что объект класса, реализующего этот интерфейс, указывает на другой объект. Звучит странно, не правда ли? Но нам нужна другая особенность: после десериализации такого объекта будет возвращен указатель не на него самого, а на тот объект, на который он указывает. В нашем случае логично было бы возвращать указатель на синглтон. Класс будет выглядеть так:
[Serializable]
internal sealed class SettingsSerializationHelper : IObjectReference 
{
    public Object GetRealObject(StreamingContext context) 
    {
        return Settings.Instance;
    }
}

Теперь мы можем сериализовывать объект класса SettingsSerializationHelper, а при десериализации получать Settings.Instance. Правда здесь есть два еще два камня:
  • Перед тем как сериализовать синглтон требуется создать объект другого класса.
  • Поля синглтона по-прежнему не сериализуются.

Рассмотрим первый камень, который не очень критичен, но явно не приятен. Решение проблемы заключено в подмене класса для сериализации внутри GetObjectData. Выглядеть это будет так (внутри синглтона):
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
    info.SetType(typeof(SettingsSerializationHelper));
}

Теперь когда мы будем сериализовывать синглтон вместо него будет сохранен объект SettingsSerializationHelper, а при десериализации мы получим обратно наш синглтон. Проверив вывод на консоль из ранее описанного примера сериализации, мы увидим, что в случае с Binary и SOAP будет выведено на консоль true, но для XML сериализации — false. Следовательно, XMLSerializer не вызывает GetObjectData и просто самостоятельно обрабатывает все public поля/свойства.

Грязные хаки

Проблема с сериализацией полей — самый крупный камень в нашем саду. К сожалению, мне не удалось найти совсем элегантное и честное решение, но получилось соорудить не очень честный, достаточно гибкий “хак”.
Для начала в методе GetObjectData добавим сохранение полей синглтона. Выглядеть это будет так:
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
    info.SetType(typeof(SettignsSerializeHelper));
    info.AddValue("_servAddr", ServerAddressr);
    info.AddValue("_port", Port);
}

Если теперь сделать SOAP сериализацию, то можно увидеть, что все поля действительно сериализованны. Однако в действительности мы сериализовывали SettignsSerializationHelper, у которого эти поля отсутствуют, а значит при десериализации у возникнут проблемы. Есть два пути решения:
  • Полностью повторить все поля синглтона в SettignsSerializationHelper. Такую подмену десериализатор вполне скушает, заполнит все поля, а внутри метода GetRealObject их надо обратно присвоить синглтону. У такого подхода есть один большой и серьёзный недостаток: ручная поддержка дублирования полей, их добавление для сериализации и десериализации. Это явно не наш бро выбор.
  • Призвать на помощь рефлексию, суррогатный селектор и чуточку linq, чтобы все было сделано за нас. Рассмотрим это подробнее.

В начале изменим метод GetObjectData:
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
    info.SetType(typeof (SettignsSerializeHelper));
    var fields = from field in typeof (Settings).GetFields(BindingFlags.Instance |
                    BindingFlags.NonPublic | BindingFlags.Public)
                    where field.GetCustomAttribute(typeof (NonSerializedAttribute)) == null
                    select field;
    foreach (var field in fields)
    {
        info.AddValue(field.Name, field.GetValue(Settings.Instance));
    }
}

Отлично, теперь когда мы захотим добавить поле в синглтон оно будет тоже сериалзованно без работы руками. Перейдем к десериализации.
Все поля синглтона должны быть повторены в SettignsSerializationHelper, но для того, чтобы избежать их реального дублирования, применим суррогатный селектор и изменим SettignsSerializationHelper.
Новый SettignsSerializationHelper:
[Serializable]
internal sealed class SettignsSerializeHelper : IObjectReference
{
    public readonly Dictionary<String, object> infos = 
            (from field in typeof (Settings).GetFields(BindingFlags.Instance 
             | BindingFlags.NonPublic | BindingFlags.Public) 
             where field.GetCustomAttribute(typeof (NonSerializedAttribute)) == null
             select field).ToDictionary(x => x.Name, x => new object());

    public object GetRealObject(StreamingContext context)
    {
        foreach (var info in infos)
        {
            typeof (Settings).GetField(info.Key, BindingFlags.Instance |  BindingFlags.NonPublic 
                                           | BindingFlags.Public).SetValue(Settings.Instance, info.Value);
        }
        return Settings.Instance;
    }
}

И так, внутри SettignsSerializationHelper создается хэш-мап, где key — имена сериализуемых полей, а value в будущем станут значениями этих полей после десериалазации. Здесь для большей инкапсуляции можно сделать infos как private и написать метод для доступка к его key-value парам, но мы не будем усложнять пример. Внутри GetRealObject мы устанавливаем синглтону его десериализованные значения полей и возвращаем ссылку на него.
Теперь осталось только заполнить infos значениями полей. Для этого будет использован селектор.
internal sealed class SettingsSurrogate : ISerializationSurrogate
{
    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context)
    {
        throw new NotImplementedException();
    }
    public object SetObjectData(object obj, SerializationInfo info, StreamingContext context,
                                ISurrogateSelector selector)
    {
        var ssh = new SettignsSerializeHelper();
        foreach (var val in info)
        {
            ssh.infos[val.Name] = val.Value;
        }
        return ssh;
    }
}

Так как селектор будет использоваться только для десериализации, то мы напишем только SetObjectData. Когда obj (десериализуемый объект) приходит внутрь селектора, его поля заполнены 0 и null не зависимо от обстоятельств (obj получается после вызова в процессе десериализации метода GetUninitializedObject из FormatterServices). Поэтому в нашем случае проще создать новый SettignsSerializationHelper и вернуть его (этот объект будет считаться десериализованным). Далее, внутри foreach заполняем infos десериализованными данными, которые потом будут присвоены полям синглтона.
И теперь пример самого процесса сериализации/десериализации:
И теперь пример самого процесса сериализации/десериализации:
using (var mem = new MemoryStream())
{
    var soapSer = new SoapFormatter();
    soapSer.Serialize(mem, Settings.Instance);
    var ss = new SurrogateSelector();
    ss.AddSurrogate(typeof(SettignsSerializeHelper),
    soapSer.Context, new SettingsSurrogate());
    soapSer.SurrogateSelector = ss;
    mem.Seek(0, SeekOrigin.Begin);
    var o = soapSer.Deserialize(mem);
    Console.WriteLine((Settings)o == Settings.Instance);
}

На консоль будет выведено true и все поля будут восстановлены. Наконец, мы закончили и привели наш сад камней в должный вид.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 6

    0
    Свойства не могут быть сделаны автоматическими, т.к. имена скрытых полей класса генерируются снова при каждой компиляции
    Всегда называются как <ИмяСвойства>k__BackingField. Во всех известных компиляторах C#.
      0
      Если обратиться к четвертому изданию CLR via C#, то там явно говорится:
      Do not use C#’s automatically implemented property feature to define properties inside types marked with the [Serializable] attribute, because the compiler generates the names of the fields and the generated names can be different each time that you recompile your code, preventing instances of your type from being deserializable.
        0
        Об этом всем твердят, но это использует слишком много народу, чтобы кто-то сломал бинарную совместимость. Так и живём. Вот имена анонимных типов — те отличаются у csc и gmcs, да.
      +1
      (Не)ожиданно на консоль будет выведено false, а это значит, что мы получили два объекта-синглтона. Такой результат можно предвидеть, если вспомнить, что в процессе десериализации с помощью рефлексии вызывается приватный конструктор и все десериализуемые значения присваиваются новому объекту. Именно эта особенность синглтона кладет первый камень в наш сад: синглтон перестает быть синглтоном.
      Неверно. BinaryFormatter использует FormatterServices.GetSafeUninitializedObject, который позволяет создать экземпляр объекта без вызова конструктора. Два разных экземпляра вы получили из-за того что статическое поле уже было проинициализировано в статическом конструкторе, а десериализатор создал свой экземпляр объекта.
        0
        Спасибо, что заметили, я действительно ошибся в этом месте. Сделал upd.
        0
        Иногда приходится городить огород, да. Но почему-то хочется взять какой-нибудь DI фреймворк и забыть о прочтенном, как о страшном сне…

        Only users with full accounts can post comments. Log in, please.