Разрешение конфликтов зависимостей сборки модуля PowerShell

При написании двоичного модуля PowerShell в C#можно использовать зависимости от других пакетов или библиотек для предоставления функциональных возможностей. Использование зависимостей от других библиотек желательно для повторного использования кода. PowerShell всегда загружает сборки в тот же контекст. Это приводит к проблемам, когда зависимости модуля конфликтуют с уже загруженными DLL-файлами и могут препятствовать использованию двух других несвязанных модулей в одном сеансе PowerShell.

Если у вас возникла эта проблема, вы видите следующее сообщение об ошибке:

сообщение об ошибке загрузки сборки

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

Почему возникают конфликты зависимостей?

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

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

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

Две зависимости DuckBuilder зависят от разных версий Newtonsoft.Json

Так как Contoso.ZipTools и Fabrikam.FileHelpers оба зависят от разных версий Newtonsoft.Json, может возникнуть конфликт зависимостей в зависимости от того, как загружается каждая зависимость.

Конфликт с зависимостями PowerShell

В PowerShell проблема конфликта зависимостей увеличится, так как собственные зависимости PowerShell загружаются в тот же общий контекст. Это означает, что подсистема PowerShell и все загруженные модули PowerShell не должны иметь конфликтующих зависимостей. Классическим примером этого является Newtonsoft.Json:

модуль FictionalTools зависит от более новой версии Newtonsoft.Json, чем PowerShell

В этом примере модуль FictionalTools зависит от Newtonsoft.Json версии 12.0.3, которая является более новой версией Newtonsoft.Json, чем 11.0.2, которая поставляется в примере PowerShell.

Заметка

Это пример. В настоящее время PowerShell 7.0 поставляется с Newtonsoft.Json 12.0.3. Более новые версии PowerShell имеют более новые версии Newtonsoft.Json.

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

Конфликт с зависимостями другого модуля

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

Это часто выглядит следующим образом:

для двух модулей PowerShell требуются разные версии зависимостей Microsoft.Extensions.Logging

В этом случае модуль FictionalTools требует более новой версии Microsoft.Extensions.Logging, чем модуль FilesystemManager.

Представьте, что эти модули загружают их зависимости, помещая сборки зависимостей в тот же каталог, что и сборка корневого модуля. Это позволяет .NET неявно загружать их по имени. Если мы запускаем PowerShell 7.0 (на вершине .NET Core 3.1), мы можем загрузить и запустить FictionalTools, а затем загрузить и запустить FilesystemManager без проблем. Однако при загрузке и запуске FilesystemManagerв новом сеансе FictionalToolsмы получаем FileLoadException из команды FictionalTools, так как для нее требуется более новая версия Microsoft.Extensions.Logging, чем загруженная. FictionalTools не удается загрузить нужную версию, так как сборка того же имени уже загружена.

PowerShell и .NET

PowerShell выполняется на платформе .NET, которая отвечает за разрешение и загрузку зависимостей сборок. Мы должны понять, как .NET работает здесь, чтобы понять конфликты зависимостей.

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

Контексты загрузки сборки

В .NET контекст загрузки сборки (ALC) — это пространство имен среды выполнения, в которое загружаются сборки. Имена сборок должны быть уникальными. Эта концепция позволяет сборкам однозначно разрешаться по имени в каждом ALC.

Загрузка ссылок на сборку в .NET

Семантика загрузки сборки зависит от реализации .NET (.NET Core и .NET Framework) и API .NET, используемого для загрузки определенной сборки. Вместо того чтобы подробно ознакомиться с этим, в разделе Дополнительные сведения о загрузке сборок .NET в каждой реализации .NET см. в разделе "Дополнительные сведения о том, как выполняется загрузка сборок .NET".

В этой статье мы рассмотрим следующие механизмы:

  • Неявная загрузка сборки (фактически Assembly.Load(AssemblyName)), когда .NET неявно пытается загрузить сборку по имени из статической ссылки на сборку в коде .NET.
  • Assembly.LoadFrom()— API загрузки, ориентированный на подключаемый модуль, который добавляет обработчики для разрешения зависимостей загруженной библиотеки DLL. Этот метод может не разрешать зависимости так, как мы хотим.
  • Assembly.LoadFile(), базовый API загрузки, предназначенный для загрузки только сборки, запрашиваемой и не обрабатывает какие-либо зависимости.

Различия в .NET Framework и .NET Core

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

В частности, .NET Framework имеет следующие функции:

  • Глобальный кэш сборок для разрешения сборки на уровне компьютера
  • Домены приложений, которые работают как песочницы внутри процесса для изоляции сборки, но также представляют уровень сериализации для противостояния
  • Ограниченная модель контекста загрузки сборки с фиксированным набором контекстов загрузки сборки, каждая из которых имеет собственное поведение:
    • Контекст загрузки по умолчанию, в котором сборки загружаются по умолчанию
    • Загрузка из контекста для загрузки сборок вручную во время выполнения
    • Контекст только отражения для безопасной загрузки сборок для чтения метаданных без их запуска
    • Таинственный пустот, в который живут сборки, загруженные с Assembly.LoadFile(string path) и Assembly.Load(byte[] asmBytes)

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

.NET Core (и .NET 5+) заменил эту сложность более простой моделью:

  • Глобальный кэш сборок не существует. Приложения приносят все свои собственные зависимости. Это удаляет внешний фактор разрешения зависимостей в приложениях, что делает разрешение зависимостей более воспроизводимым. PowerShell, как узел подключаемого модуля, немного усложняет это для модулей. Его зависимости в $PSHOME совместно используются всеми модулями.
  • Только один домен приложения и нет возможности создавать новые. Концепция домена приложения поддерживается в .NET, чтобы быть глобальным состоянием процесса .NET.
  • Новая расширяемая модель контекста загрузки сборок (ALC). Разрешение сборки можно пространству имен, поместив его в новый ALC. Процессы .NET начинаются с одного ALC по умолчанию, в который загружаются все сборки (за исключением тех, которые загружаются с Assembly.LoadFile(string) и Assembly.Load(byte[])). Но процесс может создавать и определять собственные пользовательские ALCS с собственной логикой загрузки. При загрузке сборки первый объект ALC, который он загружается, отвечает за разрешение зависимостей. Это позволяет реализовать мощные механизмы загрузки подключаемых модулей .NET.

В обеих реализациях сборки загружаются лениво. Это означает, что они загружаются при первом запуске метода, требующего их типа.

Например, ниже приведены две версии одного и того же кода, которые загружают зависимость в разное время.

Первая всегда загружает свою зависимость при вызове Program.GetRange(), так как ссылка на зависимость лексически присутствует в методе:

using Dependency.Library;

public static class Program
{
    public static List<int> GetRange(int limit)
    {
        var list = new List<int>();
        for (int i = 0; i < limit; i++)
        {
            if (i >= 20)
            {
                // Dependency.Library will be loaded when GetRange is run
                // because the dependency call occurs directly within the method
                DependencyApi.Use();
            }

            list.Add(i);
        }
        return list;
    }
}

Второй загружает свою зависимость только в том случае, если параметр limit равен 20 или более, из-за внутреннего косвенного обращения с помощью метода:

using Dependency.Library;

public static class Program
{
    public static List<int> GetNumbers(int limit)
    {
        var list = new List<int>();
        for (int i = 0; i < limit; i++)
        {
            if (i >= 20)
            {
                // Dependency.Library is only referenced within
                // the UseDependencyApi() method,
                // so will only be loaded when limit >= 20
                UseDependencyApi();
            }

            list.Add(i);
        }
        return list;
    }

    private static void UseDependencyApi()
    {
        // Once UseDependencyApi() is called, Dependency.Library is loaded
        DependencyApi.Use();
    }
}

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

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

Для PowerShell это означает, что следующие факторы могут повлиять на конфликт нагрузки сборки:

  • Какой модуль был загружен первым?
  • Был ли путь кода, использующий библиотеку зависимостей?
  • Загружает ли PowerShell конфликтующую зависимость при запуске или только в определенных путях кода?

Быстрые исправления и их ограничения

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

Изменение версии зависимости

Самый простой способ избежать конфликтов зависимостей — согласиться с зависимостью. Это может быть возможно, когда:

  • Конфликт связан с прямой зависимостью модуля и управляете версией.
  • Конфликт связан с косвенной зависимостью, но вы можете настроить прямые зависимости для использования рабочей версии косвенной зависимости.
  • Вы знаете конфликтующую версию и можете полагаться на нее.

Пакет Newtonsoft.Json является хорошим примером последнего сценария. Это зависимость PowerShell 6 и выше и не используется в Windows PowerShell. То есть простой способ устранить конфликты управления версиями заключается в том, чтобы нацелиться на самую низкую версию Newtonsoft.Json в версиях PowerShell, которые вы хотите использовать.

Например, PowerShell 6.2.6 и PowerShell 7.0.2 в настоящее время используются Newtonsoft.Json версии 12.0.3. Чтобы создать модуль, предназначенный для Windows PowerShell, PowerShell 6 и PowerShell 7, вы будете использовать Newtonsoft.Json 12.0.3 в качестве зависимости и включить его в встроенный модуль. Когда модуль загружается в PowerShell 6 или 7, сборка PowerShell Newtonsoft.Json уже загружена. Так как это версия, необходимая для модуля, разрешение завершается успешно. В Windows PowerShell сборка еще не присутствует в PowerShell, поэтому она загружается из папки модуля.

Как правило, при выборе конкретного пакета PowerShell, например Microsoft.PowerShell.Sdk или System.Management.Automation, NuGet должна иметь возможность разрешать необходимые версии зависимостей. Назначение windows PowerShell и PowerShell 6+ становится более сложным, так как необходимо выбрать один из нескольких платформ или PowerShellStandard.Library.

В случаях, когда закрепление к общей версии зависимостей не будет работать:

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

Использование зависимости вне процесса

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

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

В PowerShell существует несколько способов для этого:

  • Вызов PowerShell в качестве подпроцесса

    Чтобы запустить команду PowerShell из текущего процесса, запустите новый процесс PowerShell непосредственно с вызовом команды:

    pwsh -c 'Invoke-ConflictingCommand'
    

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

  • Система заданий PowerShell

    Система заданий PowerShell также выполняет команды вне процесса, отправляя команды в новый процесс PowerShell и возвращая результаты:

    $result = Start-Job { Invoke-ConflictingCommand } | Receive-Job -Wait
    

    В этом случае необходимо убедиться, что все переменные и состояние передаются правильно.

    Система заданий также может быть немного громоздкой при выполнении небольших команд.

  • Удаленное взаимодействие PowerShell

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

    Пример может выглядеть следующим образом:

    # Create a local PowerShell session
    # where the module with conflicting assemblies will be loaded
    $s = New-PSSession
    
    # Import the module with the conflicting dependency via remoting,
    # exposing the commands locally
    Import-Module -PSSession $s -Name ConflictingModule
    
    # Run a command from the module with the conflicting dependencies
    Invoke-ConflictingCommand
    
  • Неявное удаленное взаимодействие с Windows PowerShell

    Другим вариантом в PowerShell 7 является использование флага -UseWindowsPowerShell на Import-Module. При этом модуль импортируется через локальный сеанс удаленного взаимодействия в Windows PowerShell:

    Import-Module -Name ConflictingModule -UseWindowsPowerShell
    

    Помните, что модули могут быть несовместимы с Windows PowerShell или работать по-другому.

Когда межпроцессный вызов не должен использоваться

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

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

  • Если удаленное взаимодействие PowerShell недоступно, так как у вас нет прав на его использование или он не включен.
  • Если определенный тип .NET необходим из выходных данных в качестве входных данных в метод или другую команду. Команды, выполняемые через удаленное взаимодействие PowerShell, выдают десериализированные объекты, а не строго типизированные объекты .NET. Это означает, что вызовы методов и строго типизированные API не работают с выходными данными команд, импортированных при удаленном взаимодействии.

Более надежные решения

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

Загрузка с помощью контекстов загрузки сборок .NET Core

в .NET Core 1.0 были введены контексты загрузки сборки (ALCS) для конкретной загрузки нескольких версий одной сборки в одну среду выполнения.

В .NET они предлагают наиболее надежное решение проблемы загрузки конфликтующих версий сборки. Однако пользовательские ALCS недоступны в .NET Framework. Это означает, что это решение работает только в PowerShell 6 и более поздних версиях.

В настоящее время лучший пример использования ALC для изоляции зависимостей в PowerShell находится в Службах редакторов PowerShell, языковом сервере расширения PowerShell для Visual Studio Code. ALC используется, чтобы предотвратить столкновение зависимостей служб Редактора PowerShell с этими зависимостями в модулях PowerShell.

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

+ AlcModule.psd1
+ src/
    + TestAlcModuleCommand.cs
    + AlcModule.csproj

Реализация командлета выглядит следующим образом:

using Shared.Dependency;

namespace AlcModule
{
    [Cmdlet(VerbsDiagnostic.Test, "AlcModule")]
    public class TestAlcModuleCommand : Cmdlet
    {
        protected override void EndProcessing()
        {
            // Here's where our dependency gets used
            Dependency.Use();
            // Something trivial to make our cmdlet do *something*
            WriteObject("done!");
        }
    }
}

Манифест (значительно упрощен) выглядит следующим образом:

@{
    Author = 'Me'
    ModuleVersion = '0.0.1'
    RootModule = 'AlcModule.dll'
    CmdletsToExport = @('Test-AlcModule')
    PowerShellVersion = '7.0'
}

И csproj выглядит следующим образом:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Shared.Dependency" Version="1.0.0" />
    <PackageReference Include="Microsoft.PowerShell.Sdk" Version="7.0.1" PrivateAssets="all" />
  </ItemGroup>
</Project>

При сборке этого модуля создаваемые выходные данные имеют следующий макет:

AlcModule/
  + AlcModule.psd1
  + AlcModule.dll
  + Shared.Dependency.dll

В этом примере наша проблема находится в сборке Shared.Dependency.dll, которая представляет собой мнимую конфликтующую зависимость. Это зависимость, которую необходимо поместить за ALC, чтобы мы могли использовать версию для конкретного модуля.

Необходимо повторно спроектировать модуль, чтобы:

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

Чтобы объединить эти два требования, необходимо разделить модуль на две сборки:

  • Сборка командлетов, AlcModule.Cmdlets.dll, содержащая определения всех типов, необходимых системе модулей PowerShell для правильной загрузки модуля. А именно, любые реализации базового класса Cmdlet и класса, реализующего IModuleAssemblyInitializer, который настраивает обработчик событий для AssemblyLoadContext.Default.Resolving правильной загрузки AlcModule.Engine.dll с помощью пользовательского ALC. Так как PowerShell 7 намеренно скрывает типы, определенные в сборках, загруженных в других ALCs, все типы, которые должны быть общедоступными для PowerShell, также должны быть определены здесь. Наконец, в этой сборке необходимо определить пользовательское определение ALC. Помимо этого, как можно меньше кода должно жить в этой сборке.
  • Сборка обработчика, AlcModule.Engine.dll, которая обрабатывает фактическую реализацию модуля. Типы из этого доступны в ALC PowerShell, но изначально они загружаются с помощью пользовательского ALC. Его зависимости загружаются только в пользовательский ALC. Фактически это становится мостом между двумя ALCS.

Используя эту концепцию моста, новая ситуация сборки выглядит следующим образом:

диаграмма , представляющая AlcModule.Engine.dll мост двух alcs

Чтобы убедиться, что логика проверки зависимостей по умолчанию ALC не разрешает загрузку зависимостей в пользовательский ALC, необходимо разделить эти две части модуля в разных каталогах. Новый макет модуля имеет следующую структуру:

AlcModule/
  AlcModule.Cmdlets.dll
  AlcModule.psd1
  Dependencies/
  | + AlcModule.Engine.dll
  | + Shared.Dependency.dll

Чтобы узнать, как изменения реализации, мы начнем с реализации AlcModule.Engine.dll:

using Shared.Dependency;

namespace AlcModule.Engine
{
    public class AlcEngine
    {
        public static void Use()
        {
            Dependency.Use();
        }
    }
}

Это простой контейнер для зависимости Shared.Dependency.dll, но его следует рассматривать как API .NET для функций, которые командлеты в другой оболочке сборки для PowerShell.

Командлет в AlcModule.Cmdlets.dll выглядит следующим образом:

// Reference our module's Engine implementation here
using AlcModule.Engine;

namespace AlcModule.Cmdlets
{
    [Cmdlet(VerbsDiagnostic.Test, "AlcModule")]
    public class TestAlcModuleCommand : Cmdlet
    {
        protected override void EndProcessing()
        {
            AlcEngine.Use();
            WriteObject("done!");
        }
    }
}

На этом этапе, если бы мы загружали AlcModule и запускаем Test-AlcModule, мы получаем FileNotFoundException, когда ALC по умолчанию пытается загрузить Alc.Engine.dll для выполнения EndProcessing(). Это хорошо, так как это означает, что ALC по умолчанию не может найти зависимости, которые мы хотим скрыть.

Теперь необходимо добавить код в AlcModule.Cmdlets.dll, чтобы он знал, как разрешить AlcModule.Engine.dll. Сначала необходимо определить настраиваемый ALC для разрешения сборок из каталога Dependencies модуля:

namespace AlcModule.Cmdlets
{
    internal class AlcModuleAssemblyLoadContext : AssemblyLoadContext
    {
        private readonly string _dependencyDirPath;

        public AlcModuleAssemblyLoadContext(string dependencyDirPath)
        {
            _dependencyDirPath = dependencyDirPath;
        }

        protected override Assembly Load(AssemblyName assemblyName)
        {
            // We do the simple logic here of looking for an assembly of the given name
            // in the configured dependency directory.
            string assemblyPath = Path.Combine(
                _dependencyDirPath,
                $"{assemblyName.Name}.dll");

            if (File.Exists(assemblyPath))
            {
                // The ALC must use inherited methods to load assemblies.
                // Assembly.Load*() won't work here.
                return LoadFromAssemblyPath(assemblyPath);
            }

            // For other assemblies, return null to allow other resolutions to continue.
            return null;
        }
    }
}

Затем необходимо подключить настраиваемое приложение ALC к событию ALC по умолчанию Resolving, которое является версией ALC события AssemblyResolve в доменах приложений. Это событие запускается для поиска AlcModule.Engine.dll при вызове EndProcessing().

namespace AlcModule.Cmdlets
{
    public class AlcModuleResolveEventHandler : IModuleAssemblyInitializer, IModuleAssemblyCleanup
    {
        // Get the path of the dependency directory.
        // In this case we find it relative to the AlcModule.Cmdlets.dll location
        private static readonly string s_dependencyDirPath = Path.GetFullPath(
            Path.Combine(
                Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
                "Dependencies"));

        private static readonly AlcModuleAssemblyLoadContext s_dependencyAlc =
            new AlcModuleAssemblyLoadContext(s_dependencyDirPath);

        public void OnImport()
        {
            // Add the Resolving event handler here
            AssemblyLoadContext.Default.Resolving += ResolveAlcEngine;
        }

        public void OnRemove(PSModuleInfo psModuleInfo)
        {
            // Remove the Resolving event handler here
            AssemblyLoadContext.Default.Resolving -= ResolveAlcEngine;
        }

        private static Assembly ResolveAlcEngine(AssemblyLoadContext defaultAlc, AssemblyName assemblyToResolve)
        {
            // We only want to resolve the Alc.Engine.dll assembly here.
            // Because this will be loaded into the custom ALC,
            // all of *its* dependencies will be resolved
            // by the logic we defined for that ALC's implementation.
            //
            // Note that we're safe in our assumption that the name is enough
            // to distinguish our assembly here,
            // since it's unique to our module.
            // There should be no other AlcModule.Engine.dll on the system.
            if (!assemblyToResolve.Name.Equals("AlcModule.Engine"))
            {
                return null;
            }

            // Allow our ALC to handle the directory discovery concept
            //
            // This is where Alc.Engine.dll is loaded into our custom ALC
            // and then passed through into PowerShell's ALC,
            // becoming the bridge between both
            return s_dependencyAlc.LoadFromAssemblyName(assemblyToResolve);
        }
    }
}

В новой реализации просмотрите последовательность вызовов, возникающих при загрузке модуля, и Test-AlcModule выполняется:

схема последовательности вызовов с помощью пользовательского ALC для загрузки зависимостей

Ниже приведены некоторые точки интереса:

  • IModuleAssemblyInitializer выполняется сначала при загрузке модуля и установке события Resolving.
  • Мы не загружаем зависимости до запуска Test-AlcModule, а его метод EndProcessing() вызывается.
  • При вызове EndProcessing() по умолчанию ALC не сможет найти AlcModule.Engine.dll и вызывает событие Resolving.
  • Наш обработчик событий подключает настраиваемый ALC к ALC по умолчанию и загружает только AlcModule.Engine.dll.
  • При вызове AlcEngine.Use() в AlcModule.Engine.dllнастраиваемый ALC снова запускается для разрешения Shared.Dependency.dll. В частности, он всегда загружает нашуShared.Dependency.dll, так как он никогда не конфликтует с чем-либо в ALC по умолчанию и выглядит только в нашем каталоге Dependencies.

При сборке реализации новый макет исходного кода выглядит следующим образом:

+ AlcModule.psd1
+ src/
  + AlcModule.Cmdlets/
  | + AlcModule.Cmdlets.csproj
  | + TestAlcModuleCommand.cs
  | + AlcModuleAssemblyLoadContext.cs
  | + AlcModuleInitializer.cs
  |
  + AlcModule.Engine/
  | + AlcModule.Engine.csproj
  | + AlcEngine.cs

AlcModule.Cmdlets.csproj выглядит следующим образом:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\AlcModule.Engine\AlcModule.Engine.csproj" />
    <PackageReference Include="Microsoft.PowerShell.Sdk" Version="7.0.1" PrivateAssets="all" />
  </ItemGroup>
</Project>

AlcModule.Engine.csproj выглядит следующим образом:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Shared.Dependency" Version="1.0.0" />
  </ItemGroup>
</Project>

Таким образом, когда мы создадим модуль, наша стратегия состоит в том, чтобы:

  • Сборка AlcModule.Engine
  • Сборка AlcModule.Cmdlets
  • Скопируйте все данные из AlcModule.Engine в каталог Dependencies и помните, что мы скопировали.
  • Скопируйте все данные из AlcModule.Cmdlets, которые не были AlcModule.Engine в каталог базового модуля

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

param(
    # The .NET build configuration
    [ValidateSet('Debug', 'Release')]
    [string]
    $Configuration = 'Debug'
)

# Convenient reusable constants
$mod = "AlcModule"
$netcore = "netcoreapp3.1"
$copyExtensions = @('.dll', '.pdb')

# Source code locations
$src = "$PSScriptRoot/src"
$engineSrc = "$src/$mod.Engine"
$cmdletsSrc = "$src/$mod.Cmdlets"

# Generated output locations
$outDir = "$PSScriptRoot/out/$mod"
$outDeps = "$outDir/Dependencies"

# Build AlcModule.Engine
Push-Location $engineSrc
dotnet publish -c $Configuration
Pop-Location

# Build AlcModule.Cmdlets
Push-Location $cmdletsSrc
dotnet publish -c $Configuration
Pop-Location

# Ensure out directory exists and is clean
Remove-Item -Path $outDir -Recurse -ErrorAction Ignore
New-Item -Path $outDir -ItemType Directory
New-Item -Path $outDeps -ItemType Directory

# Copy manifest
Copy-Item -Path "$PSScriptRoot/$mod.psd1"

# Copy each Engine asset and remember it
$deps = [System.Collections.Generic.Hashtable[string]]::new()
Get-ChildItem -Path "$engineSrc/bin/$Configuration/$netcore/publish/" |
    Where-Object { $_.Extension -in $copyExtensions } |
    ForEach-Object { [void]$deps.Add($_.Name); Copy-Item -Path $_.FullName -Destination $outDeps }

# Now copy each Cmdlets asset, not taking any found in Engine
Get-ChildItem -Path "$cmdletsSrc/bin/$Configuration/$netcore/publish/" |
    Where-Object { -not $deps.Contains($_.Name) -and $_.Extension -in $copyExtensions } |
    ForEach-Object { Copy-Item -Path $_.FullName -Destination $outDir }

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

Более подробный пример см. в этом репозитории GitHub. В этом примере показано, как перенести модуль для использования ALC, сохраняя этот модуль в .NET Framework. В нем также показано, как использовать .NET Standard и PowerShell Standard для упрощения основной реализации.

Это решение также используется модулем Bicep PowerShell, а запись блога разрешение конфликтов модулей PowerShell является еще одним хорошим чтением об этом решении.

Разрешение обработчика сборки для параллельной загрузки

Хотя решение, описанное выше, требует, чтобы сборка модуля не напрямую ссылались на сборки зависимостей, а вместо этого ссылается на сборку-оболочку, которая ссылается на сборки зависимостей. Сборка-оболочка действует как мост, переадресовыв вызовы из сборки модуля в сборки зависимостей. Это делает его обычно нетривиальным объемом работы для внедрения этого решения:

  • Для нового модуля это добавит дополнительную сложность в проектирование и реализацию.
  • Для существующего модуля это потребует значительного рефакторинга

Существует упрощенное решение для параллельной загрузки сборок, подключив событие Resolving с пользовательским экземпляром AssemblyLoadContext. Использование этого метода проще для автора модуля, но имеет два ограничения. Ознакомьтесь с репозиторием PowerShell-ALC-Samples для примера кода и документации, описывающей эти ограничения и подробные сценарии для этого решения.

Важный

Не используйте Assembly.LoadFile для цели изоляции зависимостей. При использовании создается проблема идентификатора типа при загрузке другой версии той же сборки в по умолчанию. Хотя этот API загружает сборку в отдельный экземпляр AssemblyLoadContext, загруженные сборки можно обнаружить кода разрешения типов PowerShell. Таким образом, могут быть повторяющиеся типы с одинаковым полностью квалифизированным именем типа, доступными из двух разных ALCs.

Домены пользовательских приложений

Последний и самый крайний вариант изоляции сборок — использовать пользовательские домены приложений . домены приложений доступны только в .NET Framework. Они используются для обеспечения внутрипроцессной изоляции между частями приложения .NET. Одним из способов является изоляция нагрузки сборок друг от друга в рамках одного процесса.

Однако домены приложенийявляются границами сериализации. Объекты в одном домене приложения нельзя ссылаться и использовать непосредственно объектами в другом домене приложения. Это можно обойти, реализуя MarshalByRefObject. Но если вы не управляете типами, как это часто происходит с зависимостями, невозможно принудительно принудительно реализовать здесь. Единственным решением является внесение больших изменений архитектуры. Граница сериализации также имеет серьезные последствия для производительности.

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

Если вы хотите использовать домен пользовательского приложения, могут помочь следующие ссылки:

Решения для конфликтов зависимостей, которые не работают для PowerShell

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

Эти решения касаются изменений в конфигурациях развертывания для среды, в которой вы управляете приложением и, возможно, всей системой. Эти решения ориентированы на такие сценарии, как веб-серверы и другие приложения, развернутые в серверных средах, где среда предназначена для размещения приложения и может быть настроена пользователем развертывания. Они также, как правило, очень сильно ориентированы на .NET Framework, что означает, что они не работают с PowerShell 6 или более поздней версии.

Если вы знаете, что модуль используется только в средах Windows PowerShell 5.1, над которыми у вас есть общий контроль, некоторые из них могут быть вариантами. Однако модули не должны изменять состояние глобального компьютера, как это. Он может нарушить конфигурации, которые вызывают проблемы в powershell.exe, других модулях или других зависимых приложениях, которые вызывают сбой модуля в непредвиденных способах.

Перенаправление статических привязок с помощью app.config принудительного использования той же версии зависимостей

Приложения .NET Framework могут использовать app.config-файл для настройки некоторых действий приложений декларативно. Можно написать запись app.config, которая настраивает привязку сборки для перенаправления загрузки сборок в определенную версию.

Ниже приведены две проблемы, связанные с этим для PowerShell:

  • .NET Core не поддерживает app.config, поэтому это решение применяется только к powershell.exe.
  • powershell.exe — это общее приложение, которое находится в каталоге System32. Скорее всего, модуль не сможет изменить его содержимое во многих системах. Даже если это возможно, изменение app.config может нарушить существующую конфигурацию или повлиять на загрузку других модулей.

Настройка codebase с помощью app.config

По тем же причинам попытка конфигурировать параметр codebase в модулях app.config PowerShell не сработает.

Установка зависимостей в глобальный кэш сборок (GAC)

Другим способом устранения конфликтов версий зависимостей в .NET Framework является установка зависимостей в GAC, чтобы разные версии могли загружаться параллельно с GAC.

Опять же, для модулей PowerShell ниже приведены главные проблемы:

  • GAC применяется только к .NET Framework, поэтому это не помогает в PowerShell 6 и более поздних версиях.
  • Установка сборок в GAC является изменением состояния глобального компьютера и может вызвать побочные эффекты в других приложениях или других модулях. Это также может быть трудно сделать правильно, даже если модуль имеет необходимые права доступа. Получение ошибки может привести к серьезным проблемам на уровне компьютера в других приложениях .NET.

Дальнейшее чтение

Существует множество дополнительных сведений о конфликтах зависимостей версий сборки .NET. Вот некоторые хорошие прыжки с очков: