Skip to content

Instantly share code, notes, and snippets.

@eterekhin
Last active May 26, 2020 05:05
Show Gist options
  • Save eterekhin/46d4dcae74135f19b391d3070030c11a to your computer and use it in GitHub Desktop.
Save eterekhin/46d4dcae74135f19b391d3070030c11a to your computer and use it in GitHub Desktop.

Binary Serializator

Опишем бинарную сериализацию на примере BinaryFormatter'a. А далее приведу пример более современных и безопасных сериализаторов

    [Serializable]
    internal sealed class Customer
    {
        public string Name { get; set; }
    }


    [Fact]
    public void SimplyCorrectTest()
    {
        var formatter = new BinaryFormatter();
        using var ms = new MemoryStream();
        var customer = new Customer() {Name = "n a m e"};
        formatter.Serialize(ms, customer);
        ms.Position = 0; // reset the start stream pointer
        var deserializedCustomer = (Customer) formatter.Deserialize(ms);

        Assert.Equal(customer.Name, deserializedCustomer.Name);
    }

В приведенном примере мы пишем в поток бинарное представление экземпляра Customer. Кроме того в поток записывается и полное имя типа (в моем случае "DynamicTest.Tests.Customer") и полное имя сборки ("DynamicTest.Tests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"). Это нужно, для корректной десериализации. Для того, чтобы сериализация работала нужно пометить тип сериализуемого объекта атрибутом SerializableAttribute. Типы всех полей объекта также должны иметь этот атрибут. BinaryFormatter обрабатывает несколько атрибутов

Методы помеченные этим атрибутом должны принимать единсвенным параметром StreamingContext и вызываются :

  • OnSerializingAttribute - до начала сериализации
  • OnSerializedAttribute - после сериализации
  • OnDeserializingAttribute - до начала десериализации
  • OnDeserializedAttribute - после десериализации

Эти методы используются, чтобы преобразовать объект до его сериализации/десериализации, либо если есть свойство помеченное атрибутом NonSerialize и вам нужно восстановить его значение при десериализации. В примере ниже это актуально если sum - сложных объект, передавать который намного дороже, чем вычислить принимающей стороне.

    [Serializable]
    public class MyType
    {
        Int32 x, y;

        [NonSerialized]
        Int32 sum;

        public MyType(Int32 x, Int32 y)
        {
            this.x = x;
            this.y = y;
            sum = x + y;
        }

        [OnSerializing]
        private void OnSerializing(StreamingContext context)
        {
        }
    }

Также форматтер умеет сериализовывать граф объектов, особенно интересно если типы полей сериализовываемого объекта включают методы помеченные этими атрибутами. Потому что в таком случае порядок вызова этих методов при сериализации должен быть определенным. При сериализации/десериализации должны вызываться методы OnSerializing/OnDeserializing, OnSerialized,OnDeserialized сначала у вложенных полей и только потом у объекта, чьми полями они являются, так происходит потому что в код этих методов должен обращаться с уже полностью созданным объектом.

Для того чтобы получить эту последовательность, при десериализации для каждого типа определяющего метод с атрибутом OnDeserializing, этот метод сохраняется во внутренний лист. Начиная с корня(главного объекта, до листьев). После того, как десериализация закончена этот лист инвертируется и методы вызываются друг за другом.

Как работает BinaryFormatter

Это описание было актуально для какой-то вресии .net framework, но я думаю, что концепция осталась такой же, поэтому приведу ее здесь.

При сериализации первым делом вызывается метод MemberInfo[] GetSerializableMembers(Type type, StreamingContext context)

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

Далее массив MemberInfo[] передается в метод GetObjectData() вторым параметром, а первым сериализуемый объект; object?[] GetObjectData(object obj, MemberInfo[] members)

Этот метод возвращает значения полей, которые переданы в members, в том же порядке

Следующим шагом записываются Assembly Identity и type's full name в поток, а затем в поток записываются memberInfo и их значения

При десериалиации :

  1. Сначала считываются assembly name и type's full name и вызывается метод Assembly.Load(assemblyName). Тут Рихтер отмечает, что если приложение десериализовывающее объект подгружает сборки динамически, и эта сборка еще не загружена, то вполне возможно, что CLR не сможет ее найти(например, если динамические сборки подгружаются через Assembly.LoadFrom(path))и сгенерирует SerializationException. Он советует подписаться на domain.AssemblyResolve event и загрузить сборку оттуда.

  2. Вызывается метод Type? GetTypeFromAssembly(Assembly assem, string name). Который возвращает тип (почему не использовать Type Type.GetType(string name)

  3. object FormatterServices.GetUninitializedObject(type) - выделяет память под объект типа type, при этом не вызывая конструктор. Прикольный метод, также напомню, что все поля использующие статическую инициализацию, также не будут проставлены, потому что статическая иниализация это простая инициализация полей в конструкторе перед выполнение тела ctor'a, которое вы написали.

  4. Вновь создается MemberInfo[], вызовом метода GetSerializableMembers

  5. Форматтер записывает значения из полученного stream'a в массив object[]

  6. Вызывается метод object PopulateObjectMembers(object obj, MemberInfo[] members, object?[] data), который инициализирует поля в object в соответсвии с переданными members и object[]

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

[Serializable]
internal sealed class Product : ISerializable
{
    private Product(SerializationInfo info, StreamingContext context)
    {
        // this ctor will be called after Customer ctor
        Name = info.GetString(nameof(Name));
    }

    public Product()
    {
        Name = "product name";
    }

    public string Name { get; set; }

    public int GetRandomValue()
    {
        if (Name.Length < 10) return Name.Length;
        return 10;
    }

    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.AddValue(nameof(Name), Name);
    }
}

[Serializable]
internal sealed class Customer : ISerializable, IDeserializationCallback
{
    private Customer(SerializationInfo info, StreamingContext context)
    {
        // initialized all members that we saved in SerializationInfo in the GetObjectData method 
        Name = info.GetString(nameof(Name));
        Product = (Product) info.GetValue(nameof(Product), typeof(Product));
    }


    public Customer()
    {
        Name = "initialized name";
        Product = new Product();
    }

    public string Name { get; set; }

    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        // save all necessary info
        info.AddValue(nameof(Name), Name);
        info.AddValue(nameof(Product), Product);
        //RandomValueFromCustomer1 = Customer1.GetRandomValue(); // cannot be called, because Customer isn't initialized yet
    }

    public Product Product { get; }
    
    // this field have to been initialized after complete Product's initialization 
    private int randomValueFromCustomer1;

    // this method will be called after serialization is completed, therefore Product will be fully initialized and we can call its method  
    public void OnDeserialization(object? sender)
    {
        randomValueFromCustomer1 = Product.GetRandomValue(); // can be called, because Customer has already initialized 
    }
}

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

Так как сначала будет создан коренной объект Customer, то в его конструкторе нельзя вызывать методы класса Product, так как он еще не проиниализирован. Но возможность вызывать методы Product есть в методе IDeserializationCallback.OnDeserialization, который вызывается после завершения десериализации.Хотя тут тоже нужно оговориться, поскольку порядок вызова callback методов десериализуемого объекта не детерминирован, так что если Product тоже реализует IDeserializationCallback, то вызвать метод Product.GetRandomValue с детерминированным успешным результатом нельзя.

Также Рихтер повесил атрибуты [SecurityPermission(SecurityAction.Demand, SerializationFormatter = true)], который должен предотвращать создание класса из недоверенного кода, но насколько я понял, это апи устарело и теперь в .net framework поддерживается sandbox модель (stackoverflow)

Рассмотреть как реализована де/сериализация Dictionary<,>

Dictionary<,> реализует интерфейс ISerializable и имеет конструктор принимающий SerializationInfo info, StreamingContext context. Конструктор выглядит так:

    protected Dictionary(SerializationInfo info, StreamingContext context)
    {
            // We can't do anything with the keys and values until the entire graph has been deserialized
            // and we have a resonable estimate that GetHashCode is not going to fail.  For the time being,
            // we'll just cache this.  The graph is not valid until OnDeserialization has been called.
            HashHelpers.SerializationInfoTable.Add(this, info);
    }

В конструкторе просто запоминается SerializationInfo в SerializationInfoTable, это ConditionalWeakTable. Дальнейшая работа происходит в IDeserializationCallback.Deserialize() методе:

public virtual void OnDeserialization(object? sender)
{
    HashHelpers.SerializationInfoTable.TryGetValue(this, out SerializationInfo? siInfo);

    if (siInfo == null)
    {
        // We can return immediately if this function is called twice.
        // Note we remove the serialization info from the table at the end of this method.
        return;
    }

    int realVersion = siInfo.GetInt32(VersionName);
    int hashsize = siInfo.GetInt32(HashSizeName);
    _comparer = (IEqualityComparer<TKey>)siInfo.GetValue(ComparerName, typeof(IEqualityComparer<TKey>))!; // When serialized if comparer is null, we use the default.

    if (hashsize != 0)
    {
        Initialize(hashsize);

        KeyValuePair<TKey, TValue>[]? array = (KeyValuePair<TKey, TValue>[]?)
            siInfo.GetValue(KeyValuePairsName, typeof(KeyValuePair<TKey, TValue>[]));

        if (array == null)
        {
            ThrowHelper.ThrowSerializationException(ExceptionResource.Serialization_MissingKeys);
        }

        for (int i = 0; i < array.Length; i++)
        {
            if (array[i].Key == null)
            {
                ThrowHelper.ThrowSerializationException(ExceptionResource.Serialization_NullKey);
            }
            Add(array[i].Key, array[i].Value);
        }
    }
    else
    {
        _buckets = null;
    }

    _version = realVersion;
    HashHelpers.SerializationInfoTable.Remove(this);
}

Тут десериализуется версия (словарь держит номер версии, который изменяет при каждой операции добавления/удаления элемента) расписать зачем, размер словаря и компарер. Далее десериализуется KeyValuePair<TKey,TValue>[], в которых сохраняются все добавленные в словарь элементы. После этого они добавляются снова (получается структура хранения элементов до и после сериализации в словаре будут отличаться?)

Сериализация

 public virtual void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            if (info == null)
            {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.info);
            }

            info.AddValue(VersionName, _version);
            info.AddValue(ComparerName, _comparer ?? EqualityComparer<TKey>.Default, typeof(IEqualityComparer<TKey>));
            info.AddValue(HashSizeName, _buckets == null ? 0 : _buckets.Length); // This is the length of the bucket array

            if (_buckets != null)
            {
                var array = new KeyValuePair<TKey, TValue>[Count];
                CopyTo(array, 0);
                info.AddValue(KeyValuePairsName, array, typeof(KeyValuePair<TKey, TValue>[]));
            }
        }

Сериализуются только VersionName, ComparerName, HashSizeName и сам массив KeyValuePair<,>, которые не удалены (entry.next>-1), если entry удалена, то next < -1 (проверить).

Сериализация/десериализация сингтонов

В зависимости от того, куда мы будем передавать сериализованные данные для десериализации, сериализация определенных полей может не иметь смысла. Например если мы сериализуем объекты оборачивающие Windows semaphore, то нам нужно знать где эти данные будут десериализовываться. Если на другой машине, то передавать их нет смысла, так как там работает другой инстанс операционной системы, если в другом процессе, то ему нужно знать только имя семафора, потому что все обертки на семафором не могут быть переданы в другой процесс. Для того, чтобы объект мог узнать о месте где он будет десериализован при сериализации вторым параметром передается StreamingContext. Это структура в которую можно передать информацию где будет происходить сериализия, и в которой можно сохранить объект, из которого при де/сериалиазации можно будет прочитать значения, пример:

  public class ExtraInfoWhenDeserialization
    {
        public readonly string Name;

        public ExtraInfoWhenDeserialization(string name)
        {
            Name = name;
        }
    }

    [Serializable]
    public class Person : ISerializable
    {
        public string Name { get; set; }
        public int Age { get; set; }

        public Person(string name, int age)
        {
            Name = name;
            Age = age;
        }

        private Person(SerializationInfo info, StreamingContext context)
        {
            if (context.State != StreamingContextStates.Clone ||
                !(context.Context is ExtraInfoWhenDeserialization e)) return;

            Name = e.Name;
            Age = info.GetInt32(nameof(Age));
        }

        public void GetObjectData(SerializationInfo info, StreamingContext context)
        {
            info.AddValue(nameof(Age), Age);
        }
    }

...
   var someName = "some name";
    var p = new Person(someName, 17);
    
     // public enum StreamingContextStates
     // {
     //     CrossProcess = 1,
     //     CrossMachine = 2,
     //     File = 4,
     //     Persistence = 8,
     //     Remoting = 16,
     //     Other = 32,
     //     Clone = 64,
     //     CrossAppDomain = 128,
     //     All = CrossAppDomain | Clone | Other | Remoting | Persistence | File | CrossMachine | CrossProcess,
     // }
   
     // StreamingContextStates.Clone = Specifies that the object graph is being cloned. Users can assume that the cloned graph will
     // continue to exist within the same process and be safe to access handles or other references
     // to unmanaged resources.
     var streamingContext = new StreamingContext(
        StreamingContextStates.Clone,
        new ExtraInfoWhenDeserialization(someName));
    
    var formatter = new BinaryFormatter() {Context = streamingContext};
    using var m = new MemoryStream();
    formatter.Serialize(m, p);
    m.Position = 0;
    var o = (Person) formatter.Deserialize(m);
    Console.WriteLine(o.Name); // some name

В примере выше мы указали context state как StreamingContextStates.Clone и передали дополнительный объект extraInfoWhenDeserialization, который используется в конструкторе Person, вызывающимся при десериализации.

Также интересный момент в сериализации, это сериализаци singleton'ов. Например в домене есть singleton SingWrapper.Singleton, и мы сериализуем его в том же домене, очевидно, что поведение должно быть таким:

    public class SingWrapper
    {
        public static Singleton Singleton = new Singleton();
    }

    ...
    // test
    var bf = new BinaryFormatter();
    using var ms = new MemoryStream();
    bf.Serialize(ms, SingWrapper.Singleton);
    ms.Position = 0;
    var deserialized = bf.Deserialize(ms);
    Assert.Equal(SingWrapper.Singleton.GetHashCode(), deserialized.GetHashCode());

Вот как можно достичь этого поведения:

[Serializable]
public class Singleton:ISerializable
{
    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.SetType(typeof(SingletonSerializationObj));
    }
}

[Serializable]
// during deserialization the formatter sees that deserialized object implements IObjectReferenceInterface and 
// calls GetRealObject method which return object result
public class SingletonSerializationObj:IObjectReference
{
    public object GetRealObject(StreamingContext context)
    {
        return SingWrapper.Singleton;
    }
}

Это довольно странная реализация. При чтении потока сериализованных данных, форматтер понимает что для десериализации синглетона, ему нужно обратиться к другом классу, который реализует интерфейс IObjectReference, когда форматтер замечает это он вызывает метод GetRealObject(StreamingContext context) и присваивает синглетону возвращаемое значение.

Также BinaryFormatter предоставляет API, позволяющий вынести логику де/сериализации отдельный класс, что, не противоречит Single Responsibility Principle, в отличии от добавления логики в сам класс. Таким способом можно подсказать BinaryFormatter'у, что нужно использовать при де/сериализации нужно использовать другой класс:

public class PersonSurrogate : ISerializationSurrogate
{
    // call when serialization 
    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context)
    {
        // obj is the same object which was passed to the Serialize method
        if (!(obj is Person p))
            throw new ArgumentException();

        info.AddValue(nameof(p.Name), p.Name);
        info.AddValue(nameof(p.Age), p.Age);
    }

    // call when deserialization 
    public object SetObjectData(object obj, SerializationInfo info, StreamingContext context,
        ISurrogateSelector selector)
    {
        // obj is new Person object which was created by calling FormatterServices.GetUninitializedObject(), so 
        // all its fields have 0/null values 
        // also we can return new person object, then obj parameter was passedе to this method will be ignored
        if (!(obj is Person p))
            throw new ArgumentException();

        p.Name = info.GetString(nameof(p.Name));
        p.Age = info.GetInt32(nameof(p.Age));
        return p;
}
    
...
    var p = new Person("some name", 17);
    using var ms = new MemoryStream();
    var bf = new BinaryFormatter();
    var ss = new SurrogateSelector();
    ss.AddSurrogate(typeof(Person), bf.Context, new PersonSurrogate());
    bf.SurrogateSelector = ss;
    bf.Serialize(ms, p);

    ms.Position = 0;
    var deserializedObject = (Person) bf.Deserialize(ms);

    Console.WriteLine(deserializedObject.Name); // "some name"
    Console.WriteLine(deserializedObject.Age); //  17

Зачем нужны цепочки суррогатов

public interface ISurrogateSelector {
    void ChainSelector(ISurrogateSelector selector);
    ISurrogateSelector GetNextSelector();
    ISerializationSurrogate GetSurrogate(Type type, StreamingContext context, out ISurrogateSelector selector);
}

SurrogateSelector - реализация этого интерфейса, которая может добавлять суррогат, а с помощью методов интерфейса строить цепочки SurrogateSelector'ов. При этом суррогаты добавляются в hash table, ключом которой является - тип и StreamingContext. Дополнительное разбиение на ISurrogateSelector позволяет разделить логику обработки одних и тех же объектов разными способами. Я не вижу других плюсов от разбиения на суррогаты.

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

  public class PersonEncodedSurrogate : ISerializationSurrogate
{
    // call when serialization 
    public void GetObjectData(object obj, SerializationInfo info, StreamingContext context)
    {
        // obj is the same object which was passed to the Serialize method
        if (!(obj is Person p))
            throw new ArgumentException();

        info.AddValue(nameof(p.Name), EncodeString(p.Name));
        info.AddValue(nameof(p.Age), EncodeInt(p.Age));
    }

    private string DecodeString(string s) => new string(s.Select(x => (char) (x - 1)).ToArray());
    private string EncodeString(string s) => new string(s.Select(x => (char) (x + 1)).ToArray());

    private int DecodeInt(int s) => s - 1;
    private int EncodeInt(int s) => s + 1;

    // call when deserialization 
    public object SetObjectData(object obj, SerializationInfo info, StreamingContext context,
        ISurrogateSelector selector)
    {
        // obj is new Person object which was created by calling FormatterServices.GetUninitializedObject(), so 
        // all its fields have 0/null values 
        // also we can return new person object, then obj parameter was passedе to this method will be ignored
        if (!(obj is Person p))
            throw new ArgumentException();

        p.Name = DecodeString(info.GetString(nameof(p.Name)));
        p.Age = DecodeInt(info.GetInt32(nameof(p.Age)));
        return p;
    }
}

...
    var p = new Person("name", 12);
    var bf = new BinaryFormatter();
    var simpleSelector = new SurrogateSelector();
    var encodedSelector = new SurrogateSelector();
    simpleSelector.AddSurrogate(typeof(Person), new StreamingContext(StreamingContextStates.Clone),
        new PersonSurrogate());
    encodedSelector.AddSurrogate(typeof(Person), new StreamingContext(StreamingContextStates.CrossMachine),
        new PersonEncodedSurrogate());
    simpleSelector.ChainSelector(encodedSelector);
    bf.SurrogateSelector = simpleSelector;
    using var ms = new MemoryStream();
    bf.Context = new StreamingContext(StreamingContextStates.CrossMachine);
    bf.Serialize(ms, p);
    ms.Position = 0;
    var deserializedObj = (Person) bf.Deserialize(ms);

SerializationBinder

Иногда необходимо десериализовать объект в другой тип, например, сериализованный объект типа A, должен быть десериализован в объект типа B. На такой случай предлагаются SerializationBinder'a. Вот пример:

[Serializable]
public class A : ISerializable
{
    private string AValue => "name";

    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.AddValue(nameof(AValue), AValue);
    }
}

[Serializable]
public class B:ISerializable
{
    private B(SerializationInfo info, StreamingContext context)
    {
        BName = info.GetString(nameof(BName));
    }

    public string BName { get; set; }
    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        throw new NotImplementedException();
    }
}

public class MySerializationBinder : SerializationBinder
{
    public override Type BindToType(string assemblyName, string typeName)
    {
        return typeName == typeof(A).FullName ? typeof(B) : Type.GetType($"{typeName}, {assemblyName}");
    }
}

...

    var a = new A();
    using var ms = new MemoryStream();
    var bf = new BinaryFormatter();
    var ss = new SurrogateSelector();
    bf.Binder = new MySerializationBinder();
    bf.Serialize(ms, a);

    ms.Position = 0;
    var deserializedObject = (B) bf.Deserialize(ms);

    Console.WriteLine(deserializedObject.BName); // "name"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment