.Net Бинарная сериализация без ссылки на сборку с исходным типом или как договориться с BinaryFormatter

в 13:13, , рубрики: .net, assembley, BinaryFormatter, SerializationBinder, strong type name

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

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

Сотрудничаем с большой корпорацией работающей в области геологии. Исторически сложилось, так у что корпорации написано очень разного ПО для работы с данными поступающего с разных видов оборудования + анализа данных + прогнозирования. Увы, все это ПО далеко не всегда «дружит» между собой, а чаще совсем не дружит. Чтобы как-то консолидировать информацию, сейчас создается 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, а в основном приложении — нет

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

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

Автор: vlad_thinker

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js