company_banner

.NET: Лечение зависимостей

    Кто не сталкивался с проблемами из-за assembly redirect? Скорее всего все, кто разрабатывал относительно большое приложение, рано или поздно с этой проблемой столкнется.

    Сейчас я работаю в компании JetBrains, в проекте JetBrains Rider, и занимаюсь задачей миграции Rider на .NET Core. Ранее занимался общей инфраструктурой в Контуре, облачной платформой хостинга приложений.



    Под катом — расшифровка моего доклада с конференции DotNext 2019 Moscow, где я рассказал о трудностях при работе со сборками в .NET и на практических примерах показал, что бывает и как с этим бороться.


    Во всех проектах, где я работал .NET-разработчиком, мне приходилось сталкиваться с различными проблемами с подключением зависимостей и загрузкой сборок. Об этом и поговорим.

    Структура поста:


    1. Проблемы с зависимостями
    2. Strict assembly loading

    3. .NET Core

    4. Отладка загрузки сборок


    Какие вообще бывают проблемы с зависимостями?


    Когда начинали разрабатывать .NET Framework в начале 2000-х, уже была известна проблема Dependency hell, когда во всех библиотеках разработчики допускают ломающие изменения (breaking changes), и эти библиотеки становятся несовместимыми для использования с уже скомпилированным кодом. Как такую проблему решать? Первое решение очевидно. Всегда сохранять обратную совместимость. Конечно, это не очень реалистично, потому что breaking change посадить в код очень легко. Например:



    Breaking changes and .NET libraries

    Это пример специфичный для .NET. У нас есть метод, и мы решили добавить ему параметр с дефолтным значением. Код продолжит компилироваться, если мы будем его пересобирать, но бинарно это будут два абсолютно разных метода: у одного метода ноль аргументов, у второго метода один аргумент. Если разработчик внутри зависимости сломал обратную совместимость таким образом, то мы не сможем использовать код, который был скомпилирован с этой зависимостью на предыдущей версии.

    Второе решение проблем с зависимостями — это добавить версионирование библиотек, сборок — чего угодно. Здесь могут быть разные правила версионирования, суть в том, что у нас каким-то образом различные версии одной библиотеки можно друг от друга отличить, и можно понять, обновление сломает или не сломает. К сожалению, как только мы вводим версии, появляется другой сорт проблем.



    Version hell — это невозможность использовать зависимость, которая бинарно совместима, но при этом имеет версию, которая не подошла рантайму или другому компоненту, который эти версии проверяет. В .NET, типичное проявление version hell — FileLoadException, хотя файл на диске лежит, но он почему-то не грузится рантаймом.



    В .NET у сборок есть много различных версий — различным образом пытались починить version hell-ы, и смотрите, что получилось. У нас есть пакет System.Collections.Immutable. Многие его знают. У него последняя версия NuGet-пакета 1.6.0. В нём лежит библиотека, сборка с версией 1.2.4.0. Вы получили эксепшн, что у вас нет библиотеки сборки версии 1.2.4.0. Как понять, что она лежит в NuGet-пакете 1.6.0? Это будет нелегко. Кроме Assembly Version, у этой библиотеки есть ещё несколько версий. Например, Assembly File Version, Assembly Information Version. В этом NuGet-пакете на самом деле лежат три разные сборки с одинаковыми версиями (для различных версий .NET Standard).

    .NET Documentation
    Opbuild standard

    По тому, как работать со сборками в .NET, написано очень много документации. Есть .NET Guide по разработке современных приложений на .NET с учётом .NET Framework, .NET Standard, .NET Core, опенсорса и всего, что только может быть. Загрузке сборок в нём посвящено примерно 30 % всего документа. Разберем конкретные проблемы и примеры, которые могут возникнуть.

    Для чего вообще всё это нужно? Во-первых, чтобы избежать наступания на грабли. Во-вторых, вы сможете сделать жизнь пользователей ваших библиотек проще, потому что с вашей библиотекой они не будут иметь тех проблем с зависимостями, к которым они привыкли. Также это поможет справиться вам с миграцией сложных приложений на .NET Core. И в довершение всего вы сможете стать SRE, это Senior (Binding) Redirect инженер, к которому все в команде приходят и спрашивают, как написать очередной редирект.

    Strict assembly Loading


    Strict assembly loading — это основная проблема, с которой сталкиваются разработчики на .NET Framework. Она выражается в FileLoadException. Перед тем, как перейти к самому Strict assembly loading, напомню несколько базовых вещей.

    Когда вы собираете .NET-приложение, у вас на выходе получается некоторый артефакт, который обычно находится в Bin/Debug или в Bin/Release, и содержит в себе некоторый набор assembly-сборок и конфигурационных файлов. Сборки референсят друг друга по именам, Assembly name. Здесь важно понять то, что ссылки на сборку находятся непосредственно в сборке, которая на эту сборку ссылается, нет волшебных конфигурационных файлов, где прописаны assembly-референсы. Даже несмотря на то, что вам может показаться, что такие файлы есть. Референсы находятся в самих сборках в бинарном виде.

    В .NET существует процесс assembly resolving — это когда определение сборки превращается уже в настоящую сборку, которая лежит на диске или загружена куда-то в память. Аssembly resolving выполняется дважды: на этапе билда, когда у вас есть референсы в *.csproj, и на этапе рантайма, когда у вас есть референсы внутри в сборках, и они по каким-то правилам превращаются уже в сборки, которые можно загрузить.

    // Simple name
    MyAssembly, Version=6.0.0.0,
    Culture=neutral, PublicKeyToken=null

    // Strong name
    Newtonsoft.Json, Version=6.0.0.0,
    Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed // PublicKey


    Переходим к проблеме. Assembly name существуют двух основных видов. Первый вид assembly name — это Simple name. Их легко опознать по тому, что у них PublicKeyToken=null. Есть Strong name, их легко опознать по тому, что у них PublicKeyToken не null, а какое-то значение.



    Разберем пример. У нас есть программа, которая зависит от библиотеки с утилитами MyUtils, и версия MyUtils — 9.0.0.0. У этой же программы есть ссылка на другую библиотеку. Эта библиотека тоже хочет использовать MyUtils, но версии 6.0.0.0. MyUtils версии 9.0.0.0, и версии 6.0.0.0 имеют PublicKeyToken=null, то есть у них Simple name. Какая версия попадёт в бинарной артефакт, 6.0.0.0 или 9.0.0.0? 9-я версия. Сможет MyLibrary использовать MyUtils версии 9.0.0.0, которая попала в бинарный артефакт?



    На самом деле сможет, потому что MyUtils имеет Simple name и соответственно для неё Strict assembly loading-а не существует.



    Другой пример. Вместо MyUtils у нас полноценная библиотека из NuGet, которая имеет Strong name. Большинство библиотек в NuGet имеют Strong name.



    На этапе билда в BIN скопируется версия 9.0.0.0, а вот в рантайме мы получим знаменитый FileLoadException. Чтобы MyLibrary, которая хочет версию 6.0.0.0 у Newtonsoft.Json, смогла использовать версию 9.0.0.0, надо пойти и написать Binding redirect в App.config.

    Binding redirects





    Redirecting assembly versions

    В нём указано, что сборку с таким-то именем и таким-то publicKeyToken-ом нужно с такого-то диапазона версий перенаправлять на такой диапазон версий. Вроде бы очень простая запись, но тем не менее здесь она находится в App.config, а могла находиться в других файлах. Есть файл machine.config внутри .NET Framework, внутри рантайма, в котором определен какой-то стандартный набор редиректов, который может от версии к версии .NET Framework отличаться. Может получиться так, что на 4.7.1 у вас ничего не работает, а на 4.7.2 уже работает, или наоборот. Нужно иметь в виду то, что редиректы могут прийти не только из вашего .App.config, и при отладке это стоит учитывать.

    Упрощаем написание редиректов


    Никто не хочет писать Binding redirect-ы руками. Давайте отдадим эту задачу MSBuild!



    How to enable and disable automatic binding redirection

    Несколько советов по тому, как можно упростить работу с Binding redirect. Совет первый: включите автогенерацию Binding redirect в MSBuild. Включается свойством в *.csproj. При сборке проекта в бинарный артефакт будет попадать App.config, в котором указаны редиректы на версии библиотек, которые находятся в этом же артефакте. Это работает только для запускаемых приложений, console application, WinExe. Для библиотек это не работает, потому что для библиотек App.config чаще всего просто не актуален, потому что он актуален для приложения, которое запускается и само загружает сборки. Если вы сделали конфиг для библиотеки, то в приложении некоторые зависимости могут тоже отличаться от тех, которые были при сборке библиотеки, и получится то, что конфиг для библиотеки смысла особого не имеет. Тем не менее иногда для библиотек конфиги всё-таки имеют смысл.



    Ситуация, когда мы пишем тесты. Тесты обычно находятся в ClassLibrary и в них тоже нужны редиректы. Тестовые фреймворки умеют распознавать, что у библиотеки с тестами есть dll-конфиг, и променять редиректы, которые в них находятся, для кода из тестов. Можно сгенерить эти редиректы автоматически. Если у нас старый формат *.csproj, не SDK-style, то можно пойти простым путем, поменять OutputType на Exe и добавить пустой entry point, это заставит MSBuild сгенерить редиректы. Можно пойти другим путем и использовать хак. Можно дописать ещё одну property в *.csproj, которая заставит MSBuild считать, что для этого OutputType всё равно нужно генерить Binding redirect-ы. Этот способ, хоть и выглядит хаком, позволит генерить редиректы для библиотек, которые нельзя переделать в Exe, и для других типов проектов (кроме тестов).

    Для нового формата *.csproj редиректы будут генерироваться сами, если вы используете современный Microsoft.NET.Test.Sdk.

    Третий совет: не используйте генерации Binding redirect средствами NuGet. В NuGet есть возможность сгенерить Binding redirect для библиотек, которые проходят из пакетов на последние версии, но это не самый лучший вариант. Все эти редиректы придётся добавить в App.config и закоммитить, а если вы генерите редиректы средствами MSBuild, то редиректы генерятся при билде. Если вы их закоммитили, у вас могут быть merge конфликты. Вы можете сами просто забывать обновить Binding redirect в файле, а если они генерятся при билде, вы не забудете.



    Resolve Assembly Reference
    Generate Binding Redirects

    Домашнее задание для тех, кто хочет лучше разобраться с тем, как работает генерация Binding redirect-ов: узнайте, как она работает, посмотрите это в коде. Пойдите в директорию .NET, грепните везде имя property, которая используется для включения генерации. Это вообще такой распространённый подход, если есть какая-то странная property для MSBuild, можно пойти и грепнуть её использование. К счастью, используются property в XML-конфигах обычно, и вы легко найдёте их использование.

    Если вы изучите, что в этих XML-таргетах находится, вы увидите, что эта property вызывает срабатывание двух MSBuild task-ов. Первый task называется ResolveAssemblyReferences, и он генерирует набор редиректов, которые запишутся в файлы. Второй task GenerateBindingRedirects записывает результаты работы первого task в App.config. Там есть XML-логика, которая немного исправляет работу первого task и убирает некоторые лишние редиректы, либо добавляет новые.

    Альтернатива XML-конфигам


    Не всегда удобно держать редиректы в XML-конфиге. У нас может быть такая ситуация, что приложение загружает плагин, а этот плагин использует другие библиотеки, для которых нужны редиректы. В этом случае нам может быть неизвестен набор редиректов, которые нам нужны, или мы можем не захотеть генерировать XML. В такой ситуации мы можем создать AppDomain и при его создании всё-таки передать ему, где находится XML с нужными редиректами. Ещё мы можем обрабатывать ошибки загрузки сборок прямо в рантайме. Рантайм .NET такую возможность дает.

    AppDomain.CurrentDomain.AssemblyResolve += (sender, eventArgs) => 
    { 
       var name = eventArgs.Name; 
       var requestingAssembly = eventArgs.RequestingAssembly; 
       
       return Assembly.LoadFrom(...); // PublicKeyToken should be equal
    };
    


    В нём есть event, он называется CurrentDomain.AssemblyResolve. Подписавшись на этот event, мы будем получать ошибки обо всех неудачных загрузках сборок. Мы получаем имя сборки, которая не загрузилась, и получаем assembly-сборку, которая запросила загрузку первой сборки. Здесь мы можем вручную загрузить сборку из правильного места, например, отбросив версии, просто взяв её из файла, и вернуть из обработчика этого event. Либо вернуть null, если нам нечего возвращать, если мы не можем загрузить сборку. PublicKeyToken-ы должны быть одинаковыми, сборки с разными PublicKeyToken никак в рантайме между собой не дружат.



    Этот event применяется только к одному application domain. Если у нас плагин создает AppDomain внутри себя, то в них этот редирект в рантайме не сработает. Нужно каким-то образом во всех AppDomain, которые плагин создал, тоже подписаться на этот event. Мы можем сделать это, используя AppDomainManager.

    AppDomainManager — это отдельная сборка, в которой находится класс, реализующий определенный интерфейс, и один из методов этого интерфейса будет позволять инициализировать любой новый AppDomain, который создается в приложении. Как только создается AppDomain, этот метод будет вызываться. В нём вы можете подписаться на этот event.

    Strict assembly loading & .NET Core


    В .NET Core нет проблемы под названием «Strict assembly loading», которая связана с тем, что для подписанных сборок требуется ровно та версия, которая была затребована. Есть другое требование. Для всех сборок независимо от того, подписаны они Strong name-ом или нет, проверяется, что версия, которая загрузилась в рантайме, больше либо равна предыдущей. Если мы находимся в ситуации приложения с плагинами, у нас может возникнуть такая ситуация, что плагин собрали, например, с новой версии SDK, а приложение, в которое его загружают, использует до сих пор старую версию SDK, и вместо того, чтобы развалиться, мы можем тоже подписаться на этот event, но уже в .NET Core, и так же загружать сборку, которая у нас есть. Можем написать такой код:
    AppDomain.CurrentDomain.AssemblyResolve += (s, eventArgs) => 
    { 
         CheckForRecursion(); 
         var name = eventArgs.Name;
         var requestingAssembly = eventArgs.RequestingAssembly; 
        
         name.Version = new Version(0, 0); 
         
         return Assembly.Load(name); 
    };
    


    У нас есть имя сборки, которая не загрузилась, мы в обнуляем версию и вызываем Assembly.Load от этой же версии. Рекурсии здесь не будет, потому что рекурсию я уже проверил.



    Надо было загрузить MyUtils версии 0.0.2.0. В BIN у нас лежит MyUtils версии 0.0.1.0. Мы сделали редирект с версии 0.0.2.0 на версию 0.0. Версия 0.0.1.0 у нас не загрузится. У нас вылетит эксепшн, что не удалось загрузить сборку с версией 0.0.216–1. 216–1.

    new Version(0, 0) == new Version(0, 0, -1, -1) 
    
    class Version { 
         readonly int _Build; 
         readonly int _Revision; 
         readonly int _Major; 
         readonly int _Minor; 
    } 
    (ushort) -1 == 65535
    


    В классе Version не все компоненты обязательные, и вместо необязательных хранятся –1, а где-то внутри происходит переполнение, и получаются те самые 216–1. Если интересно, можете попробовать найти, где именно происходит переполнение.



    Если вы работаете со сборками reflection-ом и хотите получить все типы, то может получиться так, что у вас метод GetTypes не все типы сможет загрузить. В сборке есть класс, который унаследован от другого класса, который находится в сборке, которая не грузится.

    static IEnumerable GetTypesSafe(this Assembly assembly) 
    { 
        try 
        { 
            return assembly.GetTypes(); 
        }
        catch (ReflectionTypeLoadException e) 
       { 
            return e.Types.Where(x => x != null); 
        } 
    }
    
    


    В таком случае, проблема будет в том, что вылетит исключение ReflectionTypeLoadException. Внутри ReflectionTypeLoadException есть свойство, в котором находятся те типы, которые всё-таки удалось загрузить. Эту вещь учитывают не все популярные библиотеки. AutoMapper, по крайней мере какая-то из его версий, если сталкивалась с ReflectionTypeLoadException-ом просто падала, вместо того чтобы пойти и забрать типы изнутри исключения.

    Strong naming


    Strong-named assemblies

    Поговорим о том, что приводит к возникновению Strict assembly loading, это Strong name.
    Strong Name — это подпись сборки некоторым приватным ключом с помощью асимметричного шифрования. PublicKeyToken — это хеш публичного ключа этой сборки.

    Strong Naming позволяет отличать разные сборки, которые имеют одинаковые имена. Например, MyUtils — это не какое-то уникальное имя, может быть несколько сборок с таким названием, но при этом если подписать Strong name-ом, у них будут разные PublicKeyToken и мы их сможем таким образом отличить. Strong name необходимы для некоторых сценариев загрузки сборок.

    Например, для того чтобы установить сборку в Global Assembly Cache или чтобы загрузить сразу несколько версий side-by-side. Самое важное, что strong named сборки могут ссылаться только на другие strong named сборки. Так как некоторые пользователи хотят подписать свои сборки Strong name-ом, разработчики библиотек подписывают и свои библиотеки тоже, чтобы пользователям было их легче установить, чтобы пользователям не надо было эти библиотеки дополнительно переподписывать.

    Strong name: легаси?


    Strong naming and .NET libraries

    Microsoft явно говорит на MSDN, что Strong name для целей security использовать не стоит, что они предоставляют только для того, чтобы отличать разные сборки с одинаковыми именами. Ключ сборки никак нельзя поменять, если вы его поменяли, то вы сломаете всем вашим пользователям редиректы. Если у вас приватная часть ключа для Strong name утекла в публичный доступ, то вы никак эту подпись отозвать не сможете. Формат файла SNK, в котором находится Strong name, никак не предоставляет такой возможности, а другие форматы для хранения ключей хотя бы содержат ссылку на CRL Certificate Revocation List, по которому можно понять, что сертификат этот уже не валиден. В SNK ничего такого нет.

    В Open-source гайде есть следующие рекомендации. Во-первых, дополнительно для целей security использовать другие технологии. Во-вторых, если у вас опенсорсная библиотека, то вообще предлагается закоммитить приватную часть ключа в репозиторий, чтобы людям было легче форкнуть вашу библиотеку, пересобрать её и подложить уже готовому приложению. В-третьих, никогда не менять Strong name. Слишком разрушительное действие. Несмотря на то, что оно слишком разрушительное и о нём написано в Open-source гайде, у Microsoft иногда бывают проблемы со своими собственными библиотеками.



    Есть библиотека под названием System.Reactive. Раньше это были несколько NuGet-пакетов, один из них Rx-Linq. Это просто пример, для остальных пакетов то же самое. Во второй версии он был подписан ключом Microsoft. В третьей версии он переехал в репозиторий в проекте github.com/dotnet и стал иметь подпись .NET Foundation. У библиотеки, по сути, поменялся Strong name. Переименовался NuGet-пакет, но сборка называется внутри точно так же, как и раньше. Как сделать редирект со второй версии на третью? Этот редирект никак не сделать.

    Strong name validation


    How to: Disable strong name bypass feature

    Ещё один аргумент за то, что Strong name это уже что-то, что уходит в прошлое, и осталось чисто формальным, это то, что они не валидируются. У нас есть подписанная сборка и мы хотим исправить в ней какой-то баг, а доступа к исходникам у нас нет. Мы можем просто взять dnSpy — это утилита, которая позволяет декомпилировать и исправлять уже скомпилированные сборки. У нас всё будет работать. Потому что по дефолту включен Strong name validation bypass, то есть проверяется только то, что PublicKeyToken-ы равны, а целостность самой подписи не проверяется. Могут быть энвайронменты, в которых подпись всё-таки проверяется, и здесь яркий пример — это IIS. На IIS проверяется целостность подписи (Strong name validation bypass выключен по умолчанию), и у нас всё сломается, если мы отредактируем подписанную сборку.

    Дополнение: Можно отключить проверку подписи для сборки, используя public sign. При нём для подписи используется только публичный ключ, что обеспечивает сохранность имени сборки. Используемые Microsoft публичные ключи выложены здесь.
    В Rider public sign можно включить в свойствах проекта.





    When to change fileassembly versions

    Open-source гайд также предлагает некоторую Versioning policy, цель которой — это сократить количество необходимых Binding redirect-ов и изменений в них для пользователей на NET Framework. Эта Versioning policy заключается в том, что мы не должны менять Assembly Version постоянно. Это, конечно, может привести к проблемам с установкой в GAC, с тем, что установленный нативный образ может не соответствовать сборке и придётся выполнять JIT компиляцию заново, но, на мой взгляд, это меньшее зло, чем проблемы с версионированием. В случае с CrossGen нативные сборки не устанавливаются глобально — никаких проблем не будет.

    Например, NuGet-пакет Newtonsoft.Json, у него есть несколько версий: 12.0.1, 12.0.2 и так далее — во всех этих пакетах лежит сборка с версией 12.0.0.0. Рекомендация заключается в том, что Assembly Version надо обновлять при изменении мажорной версии NuGet-пакета.

    Выводы


    Следуйте советам для .NET Framework: генерируйте редиректы вручную и старайтесь использовать одинаковые версии зависимостей во всех проектах в своем solution. Это должно значительно минимизировать количество редиректов. Strong naming вам нужен, только если у вас есть конкретный сценарий загрузки сборок, где это необходимо, либо вы разрабатываете библиотеку и хотите упростить жизнь пользователям, которым Strong naming действительно нужен. Не меняйте Strong name.

    .NET Standard


    Переходим к .NET Standard. Он довольно тесно связан с Version hell в .NET Framework. .NET Standard — это средство для написания библиотек, которые совместимы с различными реализациями платформы .NET. Под реализациями имеются в виду .NET Framework, .NET Core, Mono, Unity и Xamarin.



    * Ccылка на документацию

    Это таблица поддержки .NET Standard различных версий различными версиями рантаймов. И вот здесь мы можем увидеть, что .NET Framework ни в каком виде не поддерживает .NET Standard версии 2.1. Релиза .NET Framework, который будет поддерживать .NET Standard 2.1 и дальнейшие версии, пока что не запланировано. Если вы разрабатываете библиотеку и хотите, чтобы она работала у пользователей на .NET Framework, вам придется иметь таргет на .NET Standard 2.0. Кроме того что .NET Framework не поддерживает последнюю версию .NET Standard, давайте обратим внимание на звёздочку. .NET Framework 4.6.1 поддерживает .NET Standard 2.0, но со звёздочкой. Такая сноска есть напрямую в документации, откуда я эту таблицу взял.



    Рассмотрим пример проекта. Приложение на .NET Framework, которое имеет одну зависимость, таргетящую .NET Standard. Примерно так: ConsoleApp и ClassLibrary. Библиотека таргетит .NET Standard. Когда мы этот проект соберем, в нашем BIN будет вот так.



    У нас там будет сотня DLL, из них имеющих отношение к приложению только одна, всё остальное пришло для того, чтобы поддержать .NET Standard. Дело в том, что .NET Standard 2.0 появился позже, чем .NET Framework 4.6.1, но при этом они оказались совместимыми по API, и разработчики решили добавить поддержку Standard 2.0 в .NET 4.6.1. Сделали её не нативно (включением netstandard.dll в сам рантайм), а так, что в BIN кладется напрямую .NET Standard *.dll и все другие assembly-фасады.



    Если посмотрим на зависимости версии .NET Framework, которые мы таргетим, и количество библиотек, которые попали в BIN, то увидим, что в 4.7.1 их уже не так много, а начиная с 4.7.2 дополнительных библиотек вообще не приезжает и .NET Standard там поддержан нативно.



    Это твит одного из разработчиков .NET, в котором, описывается эта проблема и дается рекомендация использовать .NET Framework версии 4.7.2, если у нас есть .NET Standard-библиотеки. Даже не с версии 2.0 здесь, а с версии 1.5.

    Выводы


    По возможности поднимите Target Framework в своем проекте хотя бы до 4.7.1, лучше до 4.7.2. Если вы разрабатываете библиотеку, чтобы сделать жизнь пользователей библиотеки проще, сделайте отдельный Target для .NET Framework, он позволит избежать огромного количества dll, которые могут с чем-либо законфликтовать.

    .NET Core


    Начнем с общей теории. Обсудим, как мы запускали JetBrains Rider на .NET Core, и зачем вообще нужно об этом говорить. Rider — это очень большой проект, у него огромный энтерпрайзный solution с большим количеством различных проектов, сложной системой зависимостей, его нельзя просто так взять и за один раз смигрировать на другой рантайм. Для этого нам приходится использовать некоторые хаки, которые тоже разберем.

    .NET Core приложение


    Как выглядит типичное .NET Core приложение? Зависит от того, как именно оно деплоится, во что оно в итоге собирается. У нас может быть несколько сценариев. Первый — это Framework-dependent deployment. Это то же самое, что было в .NET Framework, когда приложение использует рантайм, заранее установленный на компьютере. Может быть Self-contained deployment, это когда приложение несет рантайм за собой. И может быть ещё Single-file deployment, это когда у нас получается один exe-файл, но в случае с .NET Core внутри этого exe-файла находится артефакт Self-contained приложения, это самораспаковывающийся архив.



    Мы будем рассматривать только Framework-dependent deployment. У нас есть dll с приложением, есть два конфигурационных файла, первый из которых обязательный, это runtimeconfig.json и deps.json. Начиная с .NET Core 3.0 генерируется exe-файл, который нужен для того, чтобы приложение было удобнее запустить, чтобы не надо было вводить команду .NET, если мы на Windows. В этот артефакт попадают зависимости, начиная с .NET Core 3.0, в .NET Core 2.1 нужно сделать для этого publish или использовать другую property в *.csproj.

    Shared frameworks, .runtimeconfig.json





    .runtimeconfig.json содержит в себе настройки рантайма, которые нужны для его запуска. Там указано, под каким Shared Framework приложение будет запускаться, и выглядит это вот так. У нас указывается, что приложение будет запускаться под “Microsoft.NETCore.App” версии 3.0.0, могут быть и другие Shared Framework. Также здесь могут находиться другие настройки. Например, можно включить серверный Garbage collector.



    .runtimeconfig.json генерируется при сборке проекта. А если мы хотим включить серверный GC, то нам нужно этот файл каким-то образом модифицировать заранее, ещё до того, как мы проект соберем, либо руками дописывать. Свои настройки сюда можно добавить так. Мы можем либо включить property в *.csproj, если такая property предусмотрена разработчиками .NET, либо если property не предусмотрена, мы можем создать файл под названием runtimeconfig.template.json и прописать нужные настройки сюда. При сборке в этот template будут добавлены другие нужные настройки, например, тот же Shared Framework.



    Shared Framework — это набор, состоящий из рантайма и из библиотек. По сути то же самое, что рантайм .NET Framework, который раньше просто вот устанавливался один раз на машину и для всех был одной версией. Shared Framework-и, в отличие от единого рантайма .NET Framework, могут версионироваться, разные приложения могут использовать разные версии, установленных рантаймов. Также Shared Framework-и могут наследоваться. Сами Shared Framework-и можно посмотреть вот в таких локациях на диске, какие вообще установлены в системе.



    Есть несколько стандартных Shared Framework, например, Microsoft.NETCore.App, на котором запускаются обычные консольные приложения, AspNetCore.App — для веб-приложений, и WindowsDesktop.App — это новый Shared Framework в .NET Core третьем, на котором запускаются десктопные приложения на Windows Forms и WPF. Два последних Shared Framework по сути дополняют собой первый, необходимый для консольных приложений, то есть они не несут за собой целиком новый рантайм, а просто дополняют уже имеющийся нужными библиотеками. Это наследование выглядит так, что в директориях Shared Framework тоже есть runtimeconfig.json, в которых указан базовый Shared Framework.

    Dependency manifest (.deps.json)



    Default probing — .NET Core

    Второй конфигурационный файл — это .deps.json. Этот файл содержит описание всех зависимостей приложения или Shared Framework, или библиотеки, у библиотек .deps.json тоже есть. Там содержатся все зависимости, в том числе и транзитивные. И поведение рантайма .NET Core отличается в зависимости от того, есть .deps.json у приложения или нет. Если .deps.json нет, то приложение сможет загружать все сборки, которые есть в его Shared Framework или в его BIN директории. Если же .deps.json есть, то включается валидация. Если какой-то из сборок, которая указана в .deps.json, нет, то приложение просто не запустится. Вы увидите ошибку, котора представлена выше. Если приложение попробует в рантайме загрузить какую-то сборку, которой в .deps.json нет, например, методами Assembly load или в процессе resolve сборок, то вы увидите ошибку очень похожую на Strict assembly loading.

    JetBrains Rider


    Rider — это .NET IDE. Не все знают то, что Rider — это IDE, состоящие из фронтенда на базе IntelliJ IDEA и написанном на Java и Kotlin, и бэкенда. Бэкенд — это по сути R#, который умеет общаться с IntelliJ IDEA. Этот бэкенд — это кроссплатформенное .NET приложение уже сейчас.
    Где же оно запускается? На Windows используется .NET Framework, который установлен на компьютере пользователя. На других информационных системах, на Linux и Mac используется Mono.

    Это не идеальное решение, когда везде разные рантаймы, и хочется прийти к следующему состоянию, чтобы Rider запускался на .NET Core. Для того, чтобы производительность стала лучше, потому что в .NET Core все самые последние фичи с этим связанные. Чтобы уменьшить потребление памяти. Сейчас есть проблема, связанная с тем, как Mono работает с памятью.

    Переход на .NET Core позволит отказаться от legacy, от неподдерживаемых технологий и позволят контрибьютить какие-то фиксы для проблем, которые в рантайме нашлись. Переход на .NET Core позволит контролировать версию рантайма, то есть Rider будет запускаться уже не на .NET Framework, который установлен на компьютере пользователя, а на конкретной версии .NET Core, которую можно забандлить, в виде self-contained deployment. Переход на .NET Core в итоге позволит использовать новые API, которые завозятся именно в Core.

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

    Особенности, из-за которых перевести Rider на .NET Core сложно


    Visual Studio, даже если в неё не установлен R#, падает с Out Of Memory на больших solution-ах, внутри которых есть проекты с SDK-style *.csproj. SDK-style *.csproj — это одно из основных условий полноценного переезда .NET Core.

    Это проблема, потому что Rider основан на R#, они живут в одном репозитории, разработчики R# хотят использовать Visual Studio, чтобы разрабатывать именно свой продукт в своем продукте, чтобы догфудить его. В R# есть ссылки специфичные библиотеки под фреймворк, с которыми надо что-то делать. На Windows мы можем использовать Framework для десктоп-приложений, а на Linux и на Mac уже сейчас используются Mock-и для Windows-библиотек с минимальной функциональностью.

    Решение


    Мы решили пока оставаться на старых *.csproj, собираться под полный Framework, но так как сборки у Framework и Core бинарно совместимы, запускать их на Core. Мы не используем несовместимые апишки, добавляем все нужные конфигурационные файлы вручную и загружаем специальные версии зависимостей под .NET Core, если они есть.

    На какие хаки пришлось пойти?


    Хак один: мы хотим вызывать метод, который есть только во Framework, например, этот метод нужен в R#, а на Core не нужен. Проблема в том, что если метода нет, то тот метод, который его вызывает при JIT-компиляции ещё раньше упадет с MissingMethodException. То есть метод, которого нет, испортил метод, который его вызывает.

    static void Method() { 
      if (NetFramework) 
         CallNETFrameworkOnlyMethod();
    
      ... 
    } 
    [MethodImpl(MethodImplOptions.NoInlining)] 
    static void CallNETFrameworkOnlyMethod() { 
      NETFrameworkOnlyMethod(); 
    }
    


    Решение здесь: мы выносим вызовы несовместимых методов в отдельные методы. Есть ещё одна проблема: такой метод может заинлайниться, поэтому помечаем атрибутом NoInlining.

    Хак номер два: нам нужно уметь загружать сборки по относительным путям. У нас есть одна сборка для Framework, есть специальная версия для .NET Core. Как нам загрузить на .NET Core версию для .NET Core?



    Нам помогут .deps.json. Посмотрим на .deps.json для библиотеки System.Diagnostics.PerformanceCounter. Такая библиотека примечательная в плане её .deps.json. В нём есть секция runtime, в которой указана одна версия библиотеки с её относительным путем. Вот эта библиотека, сборка будет загружаться на всех рантаймах, и она просто кидает эксепшны. Если, например, она загружается на Linux, на Linux PerformanceCounter не работают by design, и оттуда вылетает PlatformNotSupportedException. Также в этом .deps.json есть секция runtimeTargets и вот здесь уже указана версия этой сборки конкретно для Windows, где PerformanceCounter должны работать.

    Если мы возьмем секцию runtime и пропишем в ней относительный путь до той библиотеки, которую хотим загрузить, нам это ничем не поможет. В секции runtime на самом деле прописан относительный путь внутри NuGet-пакета, а не относительно BIN. Если мы ищем эту сборку в BIN, будет использоваться оттуда только имя файла. В секции runtimeTargets прописан уже честный относительный путь, честный путь относительно BIN. Будем прописывать для наших сборок относительный путь в секции runtimeTargets. Вместо runtime identifier, который здесь «win», мы можем взять другой, который нам понравится. Например, пропишем runtime identifier «any», и будет эта сборка загружаться вообще на всех платформах. Либо пропишем «unix», и будет загружаться и на Linux, и на Mac, и так далее.

    Следующий хак: мы хотим загрузить на Linux и на Mac Mock для сборки WindowsBase. Проблема в том, что сборка с именем WindowsBase уже присутствует в Shared Framework Microsoft.NETCore.App, даже если мы находимся не на Windows. На Windows Shared Framework Microsoft.WindowsDesktop.App своим WindowsBase переопределяет ту версию, которая находится в NETCore.App. Посмотрим на .deps.json этих Framework, точнее на те секции, которые описывают WindowsBase.



    Вот отличие:



    Если какая-то библиотека конфликтует и присутствует в нескольких .deps.json, то выбирается максимальная из них по паре, состоящей из assemblyVersion и fileVersion. В .NET гайде написано, что fileVersion нужен только для того, чтобы показывать его в Windows Explorer, но это не так, он попадает в .deps.json. Это единственный случай, о котором мне известно, когда версия, прописанная в .deps.json, assemblyVersion и fileVersion, действительно используются. Во всех других случаях я видел поведение, что какие бы версии в .deps.json не были написаны, всё равно сборка будет продолжать загружаться.



    Четвертый хак. Задача: у нас появился .deps.json-файл для предыдущих двух хаков, и он нам нужен только для конкретных зависимостей. Так как .deps.json генерируются в полуручном режиме, у нас есть скрипт, который по некоторому описанию того, что туда должно попасть, его генерирует при билде, нам хочется сохранять этот .deps.json минимально возможным, чтобы нам было понятно, что вообще в нём находится. Мы хотим отключить валидацию и разрешить загрузку сборок, которые лежат в BIN, но не описаны в .deps.json.

    Решение: включаем специальную настройку в runtimeconfig. Эта настройка на самом деле нужна для обратной совместимости .NET Core 1.0.

    Выводы


    Итак, .runtime.json и .deps.json на .NET Core — это в своем роде аналоги App.config. App.config позволяют делать те же вещи, например, загружать сборки по относительным путям. Используя .deps.json, переписывая его вручную, вы можете кастомизировать загрузку сборок на .NET Core, если у вас очень сложный сценарий.

    Отладка загрузки сборок


    Я рассказал о некоторых видах проблем, поэтому нужно уметь отлаживать проблемы с загрузкой сборок. Что в этом может помочь? Во-первых, рантаймы пишут логи о том, как они загружают сборки. Во-вторых, вы можете внимательнее смотреть на эксепшены, которые к вам вылетают. Также можете ориентироваться на события рантайма.

    Fusion logs





    Back to Basics: Using Fusion Log Viewer To Debug Obscure Errors
    Fusion

    Механизм загрузки сборок в .NET Framework называется Fusion, и он умеет выдавать на диск логи о том, что он вообще делал. Чтобы включить логирование, нужно добавить специальные настройки в реестр. Это не очень удобно, поэтому имеет смысл использовать утилиты, а именно Fusion Log Viewer и Fusion++. Fusion Log Viewer — это стандартная утилита, которая входит в Visual Studio, её можно запустить из командной строки Visual Studio, Visual Studio Developer Command Prompt. Fusion++ — это опенсорсный аналог этого инструмента с более приятным интерфейсом.



    Fusion Log Viewer выглядит вот так. Это похлеще, чем WinDbg, потому что это окошко даже не растягивается. Тем не менее можно тут протыкать галочки, хотя и не всегда очевидно, какой набор галочек правильный.



    Во Fusion++ есть одна кнопка «Начать логирование», и потом появляется кнопка «Остановить логирование». В нём можно посмотреть все записи о загрузке сборок, прочитать логи о том, что именно происходило. Эти логи выглядят примерно вот так в кратком виде.



    Это эксепшн от Strict assembly loading. Если мы будем смотреть логи Fusion, мы увидим, что нам требовалось загрузить версию 9.0.0.0, после того как мы обработали все конфиги. Мы нашли файл, в котором подозревается, что есть нужная нам сборка. Мы посмотрели, что в этом файле лежит версия 6.0.0.0. У нас warning, что мы сравнили полные имена сборок, и они отличаются по мажорной версии. И дальше случилась ошибка — version mismatch.

    Runtime events





    Logging Runtime Events

    На Mono можно включить логирование с помощью переменных окружения, и логи будут в итоге писаться в stdout и stderr. Не так удобно, но решение рабочее.



    Default probing — .NET Core
    Documentation/design docs/host tracing

    В .NET Core также есть специальная переменная окружения COREHOST_TRACE, включающая запись логов в stderr. С .NET Core 3.0 можно писать логи в файл, указав путь до него в переменной COREHOST_TRACEFILE.


    Есть событие, которое срабатывает при неудачной загрузке сборок. Это event AssembleResolve. Есть второй полезный event, это FirstChanceException. Можно на него подписаться и получить ошибку о загрузке сборок, даже если кто-то написал try..catch и подропал все эксепшны в том месте, где FileLoadException произошел. Если приложение уже скомпилировано, можно запустить perfview, и он умеет мониторить .NET эксепшны, и там отыскать те, которые относились к фейлам загрузки.

    Выводы


    Перекладывайте работу на инструменты, на средства разработки, на IDE, на MSBuild, который позволяет генерировать редиректы. Вы можете перейти на .NET Core, тогда вы забудете о том, что такое Strict Assembly Loading, и сможете использовать новый API точно так же, как мы этого хотим достичь в Rider. Если подключаете библиотеке .NET Standard, то поднимите таргет-версию .NET Framework хотя бы до 4.7.1. Если кажется, что попали в безвыходную ситуацию, то ищите хаки, применяйте их, или придумывайте свои хаки для безвыходных ситуаций. И вооружайтесь средствами отладки.

    Настоятельно рекомендую ознакомиться со следующими ссылками:



    Этим летом я снова буду выступать с докладом на DotNext 2020 Piter онлайн. И если у вас уже есть билет, его можно проапгрейдить до абонемента на 8 летних конференций JUG Ru Group.
    JUG Ru Group
    Конференции для программистов и сочувствующих. 18+

    Комментарии 17

      0
      если вы генерите редиректы средствами MSBuild, то редиректы генерятся при билде

      Совет генерить редиректы средствами MSBuild к сожалению, не применим в классическом ASP.NET (System.Web)

        0

        Хак с добавлением <GenerateBindingRedirectsOutputType> не срабатывает? У меня, на вид, получилось.


        <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
        <GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
          0

          Хм, когда в последний раз смотрел, не работало.
          А он web.config генерит или app.config?

            0

            .dll.config или .exe.config в соответствии с типом бинаря проекта. Для веб приложения, скорее всего, будет *.dll.config

              +1

              А ASP.NET Classic нужно web.config

            +1

            Этот вариант не всегда работает

              0

              Спасибо за дополнение. Ниже там предлагают использовать вариант с таргетом:


                <Target Name="ForceGenerationOfBindingRedirects"
                        AfterTargets="ResolveAssemblyReferences"
                        BeforeTargets="GenerateBindingRedirects"
                        Condition="'$(AutoGenerateBindingRedirects)' == 'true'">
                  <PropertyGroup>
                    <!-- Needs to be set in a target because it has to be set after the initial evaluation in the common targets -->
                    <GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
                  </PropertyGroup>
                </Target>
          +1
          Visual Studio, даже если в неё не установлен R#, падает с Out Of Memory на больших solution-ах, внутри которых есть проекты с SDK-style *.csproj.

          150 проектов, чистая Visual Studio живет, Rider живет, VS + R# — лежит не вставая.

            +2

            Проекты старого или нового SDK-style формата? Предположу, что здесь столкнулись не с особенностями проектной модели, а с тем, что всё просто не влезло в 32-битный процесс

            +1

            Обнаружил, что в блоге .NET тулов JetBrains есть статья про проблему с большими солюшенами https://blog.jetbrains.com/dotnet/2020/05/11/story-csproj-large-solutions-memory-usage/

            +3
            И вот здесь мы можем увидеть, что .NET Framework ни в каком виде не поддерживает .NET Standard версии 2.0

            Кажется, тут должно быть 2.1, если я правильно понял мысль.
            Во всем остальном — статья прекрасная, мне была очень полезна — в какой-то момент перестал понимать, откуда столько лишних библиотек в билде 4.6.1 с target'ом на standard 2.0, а это, оказывается, признанная ошибка MS. Занятно.

              0

              Спасибо, исправил

              +1
              Что касается Fusion Log: главное его не забыть выключить! Мало того, что логи место съедают, так это ещё очень сильно замедляет работу самих .Net приложений. Я так месяц сидел с тормозящим рабочим проектом на рабочем компе и не мог понять — почему у всех наш продукт работает прилично, а у меня — как унылая какашка))
              P.S. А когда ожидать миграции Rider на Core?
                +2

                В Fusion++ есть некоторая защита от пользователя на такой случай — чтобы увидеть логи, нужно нажать Stop.


                Райдер уже сейчас (начиная с 2020.1) запускается на .NET Core под Linux и macOS. На Windows поддержка .NET Core тоже есть, но она выключена из-за некоторых технических сложностей, например нерабочего дизайнера WinForms (в его нынешней реализации в райдере используется сериализация, несовместимая с .NET Core) и необходимости переехать с ngen на crossgen. Сам по себе переход на .NET Core на Windows не даст значительного выигрыша по производительности сразу, но позволит использовать новые API и позволит не зависеть от рантайма, установленного у пользователя.

                +1
                Почти все эти грабли собрал во время работы в энтерпрайзе и во время миграции своего пет проекта на неткор.
                Хочешь не хочешь, а в кишках копаться приходится =)
                  +2

                  Спасибо за статью. Но у меня возникает стойкое ощущение, что половина этой логики была задизайнена, как JS, за 15 дней после дедлайна и корпоративной вечеринки, а вторая — панические попытки пропатчить первую половину.

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

                  Самое читаемое