- PVSM.RU - https://www.pvsm.ru -
В данной статье я поделюсь опытом бинарной сериализации типов между сборками, без ссылок друг на друга. Как оказалось, встречаются реальные и «законные» случаи, когда нужно десериализовать данные не имея сыслки на сборку где они объявлены. В статье я расскажу о сценарии в котором это потребовалось, опишу способ решения, а также расскажу о промежуточных ошибках допущенных в процессе поиска
Сотрудничаем с большой корпорацией работающей в области геологии. Исторически сложилось, так у что корпорации написано очень разного ПО для работы с данными поступающего с разных видов оборудования + анализа данных + прогнозирования. Увы, все это ПО далеко не всегда «дружит» между собой, а чаще совсем не дружит. Чтобы как-то консолидировать информацию, сейчас создается web-портал, куда разные программы выгружают свои данные в виде xml. А портал пытается создать плюс-минус-полное представление. Важный нюанс: так как разработчики портала не сильны в предметных областях каждого из приложений, то каждая команда предоставляла модуль- парсер/конвертер данных из своего xml в структуры данных портала.
Я работаю в команде разрабатывающей одно из приложений и мы довольно легко написали механизм экспорта нашей части данных. Но тут, бизнес-аналитик решил, что на центральном портале нужен один из отчетов, которые строит наша программа. Вот тут-то появилась первая проблема: отчет строится каждый раз заново и результаты никуда не сохраняются.
«Так сохраните!» — наверняка подумает читатель. Я тоже так подумал, но был тяжело разочарован требованием чтобы отчет строился уже для загруженных данных. Делать нечего — нужно переносить логику.
Было решено выделить логику построения отчета (на самом деле — это табличка в 4 колонки, но логики — вагон и большая тележка) в отдельный класс, а файл с этим классом включить по ссылке в сборку парсера. Этим мы:
Выделить логику в отдельный класс — задача не трудная. Но дальше было не все так радужно: алгоритм был основан на бизнес-объектах, перенос которых никак не укладывался в нашу концепцию. Пришлось переписывать методы так чтобы они принимали только простые типы и оперировали ими. Это было не всегда просто и местами, требовало решений красота которых оставалась под вопросом, но в целом, получилось надежное решение без явных костылей.
Оставалась одна деталь, которая, как известно, часто служит уютным прибежищем для дьявола: в наследство от предыдущих поколений разработчиков нам достался странный подход, согласно которому некоторые данные, требуемые для построения отчета, хранятся в базе в виде сериализованных бинарным способом .Net-объектов (вопросы «зачем?», «кааак?» и т.п. увы, останутся без ответа ввиду отсутствия адресатов). А входе вычислений, мы их, естественно, должны десериализовать.
Эти типы, от которых избавиться было нельзя, мы тоже включили «по ссылке», тем более, что они были довольно не сложными.
Проделав вышеописанные манипуляции и выполнив пробный запуск, я неожиданно получил ошибку времени выполнения, что
[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. Все заработало. Результаты отчета сходились тютелька в тютельку, тесты на билд-сервере прошли. С чувством выполненного долга, отправляем таску тестерам.
Расплата пришла быстро в виде баги зарегистрированной тестерами, которая гласила, что парсер на стороне портала, упал с исключением, что он не может загрузить сборку Assembley.Application (сборка из нашего приложения). Первая мысль — не почистил references. Но — нет, все впорядке, ни кто не ссылается. Пробую еще раз запустить в песочнице — все работает. Начинаю подозревать ошибку сборки, но тут, приходит в голову мысль, которая меня не радует: изменяю output path для парсера в отдельную папку, а не в общий bin-каталог приложения. И вуаля — получаю описанное исключение. Анализ стектрейса подтверждает смутные догадки — падает десериализация.
Осознание было быстрым и болезненным: замена конкретного типа на dynamic, ничего не поменяла, BinaryFormatter все так же создавал тип из внешней сборки, только в случае, когда сборка с типом лежала рядом, среда выполнения, закономерно ее подгружала, а когда сборки не стало — мы получаем ошибку.
Тут был повод загрустить. Но гугление подарило надежду в виде Класса SerializationBinder [1]. Как оказалось, он позволяет определять тип в который дессериализуются наши данные. Для этого нужно создать наследника и определить в нем следующий метод
public abstract Type BindToType(String assemblyName, String typeName);
, в котором вы можете вернуть любой тип для заданных условий.
класс BinaryFormatter имеет свойство Binder [2], куда можно заинжектить свою реализацию.
Казалось бы — проблемы нет. Но опять же остаются детали (см. выше).
Первое, вы должны обрабатывать запросы по всем типам ( и стандартным тоже).
В интернете был найден достаточно интересный вариант реализации тут [3], но там пытаются использовать 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 [4], но ничего похожего я не нашел. Писать универсальный парсер с заменой — задача не самая простая. После серии экспериментов я пришел к следующему решению: в статическом конструкторе вычитываю типы для замены, а потом ищу их имена в строке с именем создаваемого типа, и при нахождении — заменяю название сборки
/// <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
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/net/299840
Ссылки в тексте:
[1] Класса SerializationBinder : https://docs.microsoft.com/en-us/dotnet/api/system.runtime.serialization.serializationbinder?view=netframework-4.7.2
[2] Binder: https://docs.microsoft.com/en-us/dotnet/api/system.runtime.serialization.formatters.binary.binaryformatter.binder?view=netframework-4.7.2
[3] тут : https://www.codeproject.com/Tips/1101106/How-to-Serialize-Across-Assemblies-with-the-Binary
[4] AssemblyName : https://docs.microsoft.com/cs-cz/dotnet/api/system.reflection.assemblyname?view=netframework-4.7.2
[5] Источник: https://habr.com/post/430646/?utm_campaign=430646
Нажмите здесь для печати.