Как стать автором
Обновить

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

Время на прочтение 6 мин
Количество просмотров 19K
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 и все поля будут восстановлены. Наконец, мы закончили и привели наш сад камней в должный вид.
Теги:
Хабы:
+3
Комментарии 6
Комментарии Комментарии 6

Публикации

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн