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

Введение. Постановка задачи


Сотрудничаем с большой корпорацией работающей в области геологии. Исторически сложилось, так у что корпорации написано очень разного ПО для работы с данными поступающего с разных видов оборудования + анализа данных + прогнозирования. Увы, все это ПО далеко не всегда «дружит» между собой, а чаще совсем не дружит. Чтобы как-то консолидировать информацию, сейчас создается web-портал, куда разные программы выгружают свои данные в виде xml. А портал пытается создать плюс-минус-полное представление. Важный нюанс: так как разработчики портала не сильны в предметных областях каждого из приложений, то каждая команда предоставляла модуль- парсер/конвертер данных из своего xml в структуры данных портала.

Я работаю в команде разрабатывающей одно из приложений и мы довольно легко написали механизм экспорта нашей части данных. Но тут, бизнес-аналитик решил, что на центральном портале нужен один из отчетов, которые строит наша программа. Вот тут-то появилась первая проблема: отчет строится каждый раз заново и результаты никуда не сохраняются.
«Так сохраните!» — наверняка подумает читатель. Я тоже так подумал, но был тяжело разочарован требованием чтобы отчет строился уже для загруженных данных. Делать нечего — нужно переносить логику.

Этап 0. Рефакторинг. Ничего не предвещало беды


Было решено выделить логику построения отчета (на самом деле — это табличка в 4 колонки, но логики — вагон и большая тележка) в отдельный класс, а файл с этим классом включить по ссылке в сборку парсера. Этим мы:

  1. Избегаем прямого копирования
  2. Защищаемся от расхождений версий

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

Оставалась одна деталь, которая, как известно, часто служит уютным прибежищем для дьявола: в наследство от предыдущих поколений разработчиков нам достался странный подход, согласно которому некоторые данные, требуемые для построения отчета, хранятся в базе в виде сериализованных бинарным способом .Net-объектов (вопросы «зачем?», «кааак?» и т.п. увы, останутся без ответа ввиду отсутствия адресатов). А входе вычислений, мы их, естественно, должны десериализовать.

Эти типы, от которых избавиться было нельзя, мы тоже включили «по ссылке», тем более, что они были довольно не сложными.

Этап 1. Десериализация. Помни о полном имени типа


Проделав вышеописанные манипуляции и выполнив пробный запуск, я неожиданно получил ошибку времени выполнения, что
[A]Namespace.TypeA cannot be cast to [B]Namespace.TypeA. Type A originates from 'Assembley.Application, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' in the context 'Default' at location '...'. Type B originates from 'Assmbley.Portal, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' in the context 'Default' at location ''.
Первые же ссылки гугла подсказали мне, что дело в том что BinaryFormatter записывает в выходной поток не только данные, но и информацию о типе, что логично. А учитывая, что полное имя типа содержит сборку, в которой он объявлен, то очевидно вырисовывалась картина того, что я пытался один тип десериализовать, в абсолютно другой, с точки зрения .Net

Почесав затылок, я, как это бывает, принял очевидное, но, увы, порочное, решение заменить конкретный тип TypeA при десериализации на dynamic. Все заработало. Результаты отчета сходились тютелька в тютельку, тесты на билд-сервере прошли. С чувством выполненного долга, отправляем таску тестерам.

Этап 2. Основной. Сериализация между сборками


Расплата пришла быстро в виде баги зарегистрированной тестерами, которая гласила, что парсер на стороне портала, упал с исключением, что он не может загрузить сборку Assembley.Application (сборка из нашего приложения). Первая мысль — не почистил references. Но — нет, все впорядке, ни кто не ссылается. Пробую еще раз запустить в песочнице — все работает. Начинаю подозревать ошибку сборки, но тут, приходит в голову мысль, которая меня не радует: изменяю output path для парсера в отдельную папку, а не в общий bin-каталог приложения. И вуаля — получаю описанное исключение. Анализ стектрейса подтверждает смутные догадки — падает десериализация.

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

Тут был повод загрустить. Но гугление подарило надежду в виде Класса SerializationBinder . Как оказалось, он позволяет определять тип в который дессериализуются наши данные. Для этого нужно создать наследника и определить в нем следующий метод

public abstract Type BindToType(String assemblyName, String typeName);

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

Казалось бы — проблемы нет. Но опять же остаются детали (см. выше).

Первое, вы должны обрабатывать запросы по всем типам ( и стандартным тоже).
В интернете был найден достаточно интересный вариант реализации тут , но там пытаются использовать default binder от BinaryFormatter, в виде конструкции

var defaultBinder = new BinaryFormatter().Binder

Но на самом деле, по умолчанию свойство Binder равно null. Анализ исходного кода показал, что внутри BinaryFormatter проверяется ли задан ли Binder, если да — вызывается его методы, если нет — используется внутренняя логика, которая, в конечном счете сводится к

    var assembly = Assembly.Load(assemblyName);
    return FormatterServices.GetTypeFromAssembly(assembly, typeName);

Не мудрствуя лукаво, я повторил эту же логику у себя.

Вот что получилось в первой реализации

public class MyBinder : SerializationBinder
    {

 public override Type BindToType(string assemblyName, string typeName)
        {
            if (assemblyName.Contains("<ObligatoryPartOfNamespace>") )
            {
                var bindToType = Type.GetType(typeName);
                return bindToType;
            }
            else
            {
                var bindToType = LoadTypeFromAssembly(assemblyName, typeName);
                return bindToType;
            }
        }

        private Type LoadTypeFromAssembly(string assemblyName, string typeName)
        {
            if (string.IsNullOrEmpty(assemblyName) ||
                string.IsNullOrEmpty(typeName))
                return null;
            var assembly = Assembly.Load(assemblyName);
            return FormatterServices.GetTypeFromAssembly(assembly, typeName);
        }
}

Т.е. проверяется, если пространство имен относится к проекту — возвращаем тип из текущего домена, если системный тип — подгружаем из соответствующей сборки

Выглядит логично. Запускаем тестируем: приходит наш тип — подменяем, он создается. Ура! Приходит string — идем по ветке с загрузкой из сборки. Работает! Открываем виртуальное шампанское…

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

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

       /// <summary>
        /// The  types that may be changed to local
        /// </summary>
        protected static IEnumerable<Type> _changedTypes;

        static MyBinder()
        {
         var executingAssembly = Assembly.GetCallingAssembly();
            var name = executingAssembly.GetName().Name;
            _changedTypes = executingAssembly.GetTypes().Where(t => t.Namespace != null && !t.Namespace.Contains(name) && !t.Name.StartsWith("<"));
//!t.Namespace.Contains(name) - т.е тип объявлен  в этой сборке, но в пространстве имен эта сборка не упоминается
//С "<' начинаются технические типы создаваемые компилятором - нас они не интересуют
        }

        private static string CorrectTypeName(string name)
        {
            foreach (var changedType in _changedTypes)
            {
                var ind = name.IndexOf(changedType.FullName);
                if (ind != -1)
                {
                    var endIndex = name.IndexOf("PublicKeyToken", ind)  ;
                    if (endIndex != -1)
                    {
                        endIndex += +"PublicKeyToken".Length + 1;
                        while (char.IsLetterOrDigit(name[endIndex++])) { }
                        var sb = new StringBuilder();
                        sb.Append(name.Substring(0, ind));
                        sb.Append(changedType.AssemblyQualifiedName);
                        sb.Append(name.Substring(endIndex-1));
                        name = sb.ToString();
                    }
                }
            }

            return name;
        }

        /// <summary>
        /// look up the type locally if the assembly-name is "NA"
        /// </summary>
        /// <param name="assemblyName"></param>
        /// <param name="typeName"></param>
        /// <returns></returns>
        public override Type BindToType(string assemblyName, string typeName)
        {
           typeName = CorrectTypeName(typeName);
            if (assemblyName.Contains("<ObligatoryPartOfNamespace>") || assemblyName.Equals("NA"))
            {
                var bindToType = Type.GetType(typeName);
                return bindToType;
            }
            else
            {
                var bindToType = LoadTypeFromAssembly(assemblyName, typeName);
                return bindToType;
            }
        }


Как вы видите, я отталкивался от того что PublicKeyToken — последний в описании типа. Возможно, это не 100% надежность, но на моих тестах я не нашел случаев, когда это не так.

Таким образом, строка вида
«System.Collections.Generic.Dictionary`2[[SomeNamespace.CustomType, Assembley.Application, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null],[System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]»

превращается в
«System.Collections.Generic.Dictionary`2[[SomeNamespace.CustomType, Assembley.Portal, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null],[System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]»

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


                BinaryFormatter binForm = new BinaryFormatter();
#if EXTERNAL_LIB
                binForm.Binder = new MyBinder();

#endif

Соответственно, в сборке портала определяем макрос EXTERNAL_LIB, а в основном приложении — нет

«Нелирическое отступление»


На самом деле, в процессе кодинга, с целью побыстрее проверить решение я совершил один просчет, стоивший мне, наверное, определенного количества нервных клеток: для начала я просто захардкодил подмену типов для Dicitionary. В итоге, после дессериализации, получался пустой Dictionary, который к тому же «падал» при попытке произвести с ним какие-то операции. Я уже начинал думать, что BinaryFormatter не обманешь, начал отчаянные эксперименты с попыткой написать наследник Dictionary. К счастью, я почти вовремя остановился и вернулся к написанию универсального механизма подмены и, реализовав его, я понял, что для создания Dictionary мало переопределить его тип: нужно еще позаботиться о типах для KeyValuePair<TKey,TValue>, Comparer, которые также запрашиваются у Binder'а


Вот такие приключения с бинарной сериалтзацией. Буду благодарен за обратную связь.

Комментарии (19)


  1. eugene_bb
    21.11.2018 21:19

    Можно попробовать proto3, в качестве бонуса получите совместимость с другими платформами.


  1. hodzanassredin
    21.11.2018 21:37
    +1

    А у нас была задача заставить авро сериализатор работать с динамическими типами. Проще всего было генерить тип на лету.

            public static Type CreateType(this IDictionary<string, PropertySchema> properties, string typeName)
            {
                var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(Guid.NewGuid().ToString()), AssemblyBuilderAccess.Run);
                ModuleBuilder module = assemblyBuilder.DefineDynamicModule("tmp");
                
                TypeBuilder typeBuilder = module.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class);
    
                AddAttribute<DataContractAttribute>(typeBuilder);
                // Loop over the attributes that will be used as the properties names in out new type
                foreach (var prop in properties)
                {
                    string propertyName = prop.Key;
    
                    var propType = prop.Value.Type;
    
                    if (propType == typeof(TimeSpan)) propType = typeof(DateTime);//timespan is not supported by avro core lib
    
                    // Generate a private field
                    FieldBuilder field = typeBuilder.DefineField("_" + propertyName, propType, FieldAttributes.Private);
                    // Generate a public property
                    PropertyBuilder property =
                        typeBuilder.DefineProperty(propertyName,
                                         PropertyAttributes.None,
                                         propType,
                                         new Type[] { propType });
    
    
                    if (prop.Value.IsNullable) {
                        AddAttribute<NullableSchemaAttribute>(property);
                    }
                    AddAttribute<DataMemberAttribute>(property);
    
                    MethodAttributes GetSetAttr =
                        MethodAttributes.Public |
                        MethodAttributes.HideBySig;
    
                    MethodBuilder currGetPropMthdBldr =
                        typeBuilder.DefineMethod("get_value",
                                                   GetSetAttr,
                                                   propType,
                                                   Type.EmptyTypes);
    
                    ILGenerator currGetIL = currGetPropMthdBldr.GetILGenerator();
                    currGetIL.Emit(OpCodes.Ldarg_0);
                    currGetIL.Emit(OpCodes.Ldfld, field);
                    currGetIL.Emit(OpCodes.Ret);
    
                    MethodBuilder currSetPropMthdBldr =
                        typeBuilder.DefineMethod("set_value",
                                                   GetSetAttr,
                                                   null,
                                                   new Type[] { propType });
    
                    ILGenerator currSetIL = currSetPropMthdBldr.GetILGenerator();
                    currSetIL.Emit(OpCodes.Ldarg_0);
                    currSetIL.Emit(OpCodes.Ldarg_1);
                    currSetIL.Emit(OpCodes.Stfld, field);
                    currSetIL.Emit(OpCodes.Ret);
    
                    property.SetGetMethod(currGetPropMthdBldr);
                    property.SetSetMethod(currSetPropMthdBldr);
                }
    
                return typeBuilder.CreateType();
            }


    1. vlad_thinker Автор
      22.11.2018 00:28

      Здорово. Интересный подход


    1. Rambalac
      22.11.2018 03:50

      Зачем этот огород, если есть ExpandoObject и dynamic?


      1. Hydro
        22.11.2018 09:42

        Нужно когда требуется выжать перформанс, т.к. оно работает ГОРАЗДО быстрее ExpandoObject и dynamic.
        Но не уверен, что у автора коммента стояла такая задача)) Поэтому соглашусь про ExpandoObject)


      1. hodzanassredin
        22.11.2018 11:18

        Задача: копировать данные из azure tables и сохранять их в компактном бинарном формате. Когда мы берем данныиз из таблиц, то они попадают к нам в полуструктурированном виде как IEnumerable<IDictionary<string,object>>. Тоесть у нас типа вообще нет. Но мы знаем что строки все пишутся туда примерно в одном и томже виде, тоесть имеют один и тот же набор полей. Поэтому мы проходим первые N строк и формируем описательный обьект IDictionary<string,Type>, теперь мы знаем схему данных, но типа у нас нет. Мы формируем тип данных динамически, как было описано выше, назовем его как NewType.

        Зачем это надо?
        Дальше мы могли бы в случае например Json сериализатора просто скормить ему IDictionary<string,object> или же NewType и результат был бы идентичным, так как там не требуется схема данных. В случае же бинарных форматов нам необходимо описать схему в виде требуемым конкретным сериализатором. Хотя avro сериализатор и умеет работать с динамическими типами, но при создании мы должны ему задать схему.

        const string Schema = @"{
                                    ""type"":""record"",
                                    ""name"":""Microsoft.Hadoop.Avro.Specifications.SensorData"",
                                    ""fields"":
                                        [
                                            {
                                                ""name"":""Location"",
                                                ""type"":
                                                    {
                                                        ""type"":""record"",
                                                        ""name"":""Microsoft.Hadoop.Avro.Specifications.Location"",
                                                        ""fields"":
                                                            [
                                                                { ""name"":""Floor"", ""type"":""int"" },
                                                                { ""name"":""Room"", ""type"":""int"" }
                                                            ]
                                                    }
                                            },
                                            { ""name"":""Value"", ""type"":""bytes"" }
                                        ]
                                }";
        
                //Create a generic serializer based on the schema
                var serializer = AvroSerializer.CreateGeneric(Schema);
                var rootSchema = serializer.WriterSchema as RecordSchema;
        
                //Create a memory stream buffer
                using (var stream = new MemoryStream())
                {
                    //Create a generic record to represent the data
                    dynamic location = new AvroRecord(rootSchema.GetField("Location").TypeSchema);
                    location.Floor = 1;
                    location.Room = 243;
        
                    dynamic expected = new AvroRecord(serializer.WriterSchema);
                    expected.Location = location;
                    expected.Value = new byte[] { 1, 2, 3, 4, 5 };
        
                    Console.WriteLine("Serializing Sample Data Set...");
        
                    //Serialize the data
                    serializer.Serialize(stream, expected);
        


        Итак нам надо сформировать описание схемы в авро формате. И тут проблема, что это хоть и возможно сделать как функцию String GetAvroSchema(IDictionary<string,Type> schema), но довольно сложно. Плюс когда мы начинаем добавлять другие сериализаторы нам потребуется писать еще одну функцию для каждого, например для protobuff String GetProto3Schema(IDictionary<string,Type> schema) и дальше будет куча велосипедов. В данном случае гораздо удобнее сгенерировать один тип и дальше создавать сериализаторы нормальным путем.

        public static class AvroExtensions {
                public static string GetAvroSchemaFromType(Type t) {
                    var m = typeof(AvroExtensions).GetMethod(nameof(GetAvroSchema));
                    var ctor = m.MakeGenericMethod(new[] { t });
                    return (string)ctor.Invoke(null, null);
                }
        
                public static string GetAvroSchema<T>()
                {
                    var s = AvroSerializer.Create<T>();
                    return s.WriterSchema.ToString();
                }
            }


        Далее стоит вопрос скорости работы всего этого дела. Динамический подход работает очень меееедлеееенннннноооо. Проще сделать уже сериализатор полностью на основе типа и писать туда обьекты этого типа, сериализаторы могут использовать тогда различные оптимизационные трюки. Вопрос только в том, как нам создать эффективную функцию NewType Cast(IDictionary<string,object> obj)? Но и тут нам помогает тип. Так как многие источники также умеют работать и с типами, мы можем задать типизированный доступ к azure table.
        public IEnumerable<T> GetEntities<T>(string table) where T : ITableEntity, new()
            {
                var table = _tableClient.GetTableReference(table);
                var query = table.CreateQuery<T>();
                //...
                return rows;
            } 


        И копирование у нас будет тривиальным:
        public static void Copy<T>()
                {
                    var s = Source.Create<T>();
                    var d = Dest.Create<T>();
                    foreach (var item in s)
                    {
                        Dest.write(item);
                    }
                }
        
                public static void Copy(Type t)
                {
                    var m = typeof(AvroExtensions).GetMethod(nameof(Copy));
                    var ctor = m.MakeGenericMethod(new[] { t });
                    return (string)ctor.Invoke(null, null);
                }


  1. vlad_thinker Автор
    22.11.2018 09:58

    Как я писал, dynamic — это не сферический тип в вакууме, а вполне кокретный, просто на этапе компиляции мы о нем не знаем, но в рантйме он вполне конкретный и чтобы его создать требуется информация о типе.
    Также не совсем понимаю (может спросонья), как поможет ExpandoObject, если у нас уже есть массив байт в который сериализован конкретный тип (момент сериализации мы не можем менять) и надо его десериализовать. Я так понимаю, можно избежать включения классов по ссылке, заменив динамически, но вот с десиарилизацией


  1. AntonAZ
    22.11.2018 10:20
    -1

    Когда у меня возникла проблема с десериализацией объектов, нашел где-то:


    public sealed class dsreplace : System.Runtime.Serialization.SerializationBinder
    {
        public override Type BindToType(string assemblyName, string typeName)
        {
            var outType =
                       Type.GetType(String.Format("{0}, {1}", typeName, assemblyName), false)
                   ?? Type.GetType(String.Format("{0}, {1}", typeName, assemblyName), false, false)
                   ?? Type.GetType(String.Format("{0}", typeName), false, false));
            return outType;
        }
    }

    Но все типы уже предварительно загружены.


    1. mayorovp
      22.11.2018 10:27

      Это не сработает для Dictionary


  1. mayorovp
    22.11.2018 10:26

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


    1. vlad_thinker Автор
      22.11.2018 12:02

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


      1. mayorovp
        22.11.2018 12:51

        В базе портала или приложения? Если первое, то что они там вообще делали когда их прочитать было невозможно? Если второе, то что мешает сменить формат в процессе передачи на портал? Если база — общая, то зачем вообще xml?


        1. vlad_thinker Автор
          22.11.2018 13:17

          В базе приложения. и они автоматом уже были переданы в базу портала. Опять же если с самого начала об этом подумали — все было бы по другому


  1. mayorovp
    22.11.2018 10:31

    Вы не пытались подписаться на AssemblyResolve и переопределить сборку?


        AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
        // ...
    
        private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
        {
            if (args.Name == "foo")
                return typeof(Bar).Assembly;
    
            return null;
        }

    Еще есть событие TypeResolve, на случай если имена типов разные.


    Достоинство этого метода — в том, что имя типа парсится системными средствами.


    1. vlad_thinker Автор
      22.11.2018 10:41

      Хм, вот этот способ упустил, действительно. Спасибо


  1. UnclShura
    22.11.2018 21:21

    А оно ведь сломается если layout объектов разный? Т.е. все поля формально есть но в другом порядке или поле добавилось.


    1. vlad_thinker Автор
      22.11.2018 22:43

      ну да, компилиться всегда должно в комлексе


      1. vlad_thinker Автор
        22.11.2018 23:54

        Хотя, если десерелизация через рефлексию происходит (надо проверить) и используются имена полей — тогда все ок


        1. gBear
          23.11.2018 17:46

          Вообще-то «deserialization constructor» далеко не вчера «изобрели» :-) Он с нами, емнип, со времен появления ISerializable. Так что, все там проще на много в этом плане.