Resolvendo conflitos de dependência de assembly do módulo PowerShell

Ao escrever um módulo binário do PowerShell em C#, é natural depender de outros pacotes ou bibliotecas para fornecer funcionalidade. Tomar dependências de outras bibliotecas é desejável para a reutilização de código. O PowerShell sempre carrega assemblies no mesmo contexto. Isso apresenta problemas quando as dependências de um módulo entram em conflito com DLLs já carregadas e pode impedir o uso de dois módulos não relacionados na mesma sessão do PowerShell.

Se você teve esse problema, você viu uma mensagem de erro como esta:

Mensagem de erro de conflito de carregamento de assembly

Este artigo analisa algumas maneiras pelas quais os conflitos de dependência ocorrem no PowerShell e maneiras de mitigar os problemas de conflito de dependência. Mesmo que você não seja um autor de módulo, há alguns truques aqui que podem ajudá-lo com conflitos de dependência que ocorrem nos módulos que você usa.

Por que ocorrem conflitos de dependência?

No .NET, conflitos de dependência ocorrem quando duas versões do mesmo assembly são carregadas no mesmo contexto de carga de assembly. Este termo significa coisas ligeiramente diferentes em diferentes plataformas .NET, que é abordado mais adiante neste artigo. Esse conflito é um problema comum que ocorre em qualquer software onde dependências versionadas são usadas.

As questões de conflito são agravadas pelo fato de que um projeto quase nunca depende deliberada ou diretamente de duas versões da mesma dependência. Em vez disso, o projeto tem duas ou mais dependências que exigem uma versão diferente da mesma dependência.

Por exemplo, digamos que seu aplicativo .NET, DuckBuilder, traz duas dependências, para executar partes de sua funcionalidade e tem esta aparência:

Duas dependências do DuckBuilder dependem de versões diferentes do Newtonsoft.Json

Como Contoso.ZipTools e Fabrikam.FileHelpers ambos dependem de versões diferentes do Newtonsoft.Json, pode haver um conflito de dependência dependendo de como cada dependência é carregada.

Conflito com as dependências do PowerShell

No PowerShell, o problema do conflito de dependência é ampliado porque as próprias dependências do PowerShell são carregadas no mesmo contexto compartilhado. Isso significa que o mecanismo do PowerShell e todos os módulos do PowerShell carregados não devem ter dependências conflitantes. Um exemplo clássico disso é Newtonsoft.Json:

O módulo FictionalTools depende da versão mais recente do Newtonsoft.Json do que do PowerShell

Neste exemplo, o módulo FictionalTools depende da versão 12.0.3Newtonsoft.Json , que é uma versão mais recente do Newtonsoft.Json do 11.0.2 que a fornecida no exemplo PowerShell.

Nota

Este é um exemplo. Atualmente, o PowerShell 7.0 é fornecido com Newtonsoft.Json 12.0.3. As versões mais recentes do PowerShell têm versões mais recentes do Newtonsoft.Json.

Como o módulo depende de uma versão mais recente do assembly, ele não aceitará a versão que o PowerShell já carregou. Mas como o PowerShell já carregou uma versão do assembly, o módulo não pode carregar sua própria versão usando o mecanismo de carregamento convencional.

Conflito com as dependências de outro módulo

Outro cenário comum no PowerShell é que um módulo é carregado que depende de uma versão de um assembly e, em seguida, outro módulo é carregado posteriormente que depende de uma versão diferente desse assembly.

Isso geralmente se parece com o seguinte:

Dois módulos do PowerShell exigem versões diferentes da dependência Microsoft.Extensions.Logging

Neste caso, o FictionalTools módulo requer uma versão mais recente do Microsoft.Extensions.Logging que o FilesystemManager módulo.

Imagine que esses módulos carregam suas dependências colocando os assemblies de dependência no mesmo diretório que o assembly do módulo raiz. Isso permite que o .NET os carregue implicitamente pelo nome. Se estivermos executando o PowerShell 7.0 (além do .NET Core 3.1), podemos carregar e executar FictionalToolse, em seguida, carregar e executar FilesystemManager sem problemas. No entanto, em uma nova sessão, se carregarmos e executarmos FilesystemManager, então carregarmos FictionalTools, obteremos um FileLoadException do FictionalTools comando porque ele requer uma versão mais recente do Microsoft.Extensions.Logging que a carregada. FictionalTools Não é possível carregar a versão necessária porque um assembly com o mesmo nome já foi carregado.

PowerShell e .NET

O PowerShell é executado na plataforma .NET, que é responsável por resolver e carregar dependências de assembly. Precisamos entender como o .NET opera aqui para entender os conflitos de dependência.

Também devemos confrontar o fato de que diferentes versões do PowerShell são executadas em diferentes implementações do .NET. Em geral, o PowerShell 5.1 e inferior são executados no .NET Framework, enquanto o PowerShell 6 e superior são executados no .NET Core. Essas duas implementações do .NET carregam e manipulam assemblies de forma diferente. Isso significa que a resolução de conflitos de dependência pode variar dependendo da plataforma .NET subjacente.

Contextos de carga de montagem

No .NET, um Assembly Load Context (ALC) é um namespace de tempo de execução no qual os assemblies são carregados. Os nomes dos assemblies devem ser exclusivos. Este conceito permite que as montagens sejam resolvidas exclusivamente pelo nome em cada ALC.

Carregamento de referência de montagem no .NET

A semântica do carregamento do assembly depende da implementação do .NET (.NET Core vs .NET Framework) e da API do .NET usada para carregar um assembly específico. Em vez de entrar em detalhes aqui, há links na seção Leitura adicional que entram em grandes detalhes sobre como o carregamento de assembly .NET funciona em cada implementação .NET.

Neste artigo, vamos nos referir aos seguintes mecanismos:

  • Carregamento implícito de assembly (efetivamente Assembly.Load(AssemblyName)), quando o .NET tenta implicitamente carregar um assembly pelo nome a partir de uma referência de assembly estático no código .NET.
  • Assembly.LoadFrom(), uma API de carregamento orientada a plug-ins que adiciona manipuladores para resolver dependências da DLL carregada. Este método pode não resolver dependências da maneira que queremos.
  • Assembly.LoadFile(), uma API de carregamento básica destinada a carregar apenas o assembly solicitado e não lida com dependências.

Diferenças no .NET Framework vs .NET Core

A maneira como essas APIs funcionam mudou de maneiras sutis entre o .NET Core e o .NET Framework, então vale a pena ler os links incluídos. É importante ressaltar que os Contextos de Carga de Assembly e outros mecanismos de resolução de assembly foram alterados entre o .NET Framework e o .NET Core.

Em particular, o .NET Framework tem os seguintes recursos:

  • O cache de assembly global, para resolução de assembly em toda a máquina
  • Domínios de aplicativo, que funcionam como caixas de proteção em processo para isolamento de montagem, mas também apresentam uma camada de serialização para lidar
  • Um modelo de contexto de carga de montagem limitada que tem um conjunto fixo de contextos de carga de montagem, cada um com seu próprio comportamento:
    • O contexto de carga padrão, onde os assemblies são carregados por padrão
    • O contexto load-from, para carregar assemblies manualmente em tempo de execução
    • O contexto somente reflexão, para carregar assemblies com segurança para ler seus metadados sem executá-los
    • O misterioso vazio que se monta carregado Assembly.LoadFile(string path) e Assembly.Load(byte[] asmBytes) vive

Para obter mais informações, consulte Práticas recomendadas para carregamento de montagem.

O .NET Core (e o .NET 5+) substituíram essa complexidade por um modelo mais simples:

  • Sem cache de assembly global. Os aplicativos trazem todas as suas próprias dependências. Isso remove um fator externo para a resolução de dependência em aplicativos, tornando a resolução de dependência mais reproduzível. O PowerShell, como host de plug-in, complica um pouco isso para os módulos. Suas dependências são $PSHOME compartilhadas com todos os módulos.
  • Apenas um domínio de aplicativo e nenhuma capacidade de criar novos. O conceito de domínio de aplicativo é mantido no .NET para ser o estado global do processo .NET.
  • Um novo modelo extensível de Assembly Load Context (ALC). A resolução do assembly pode ser namespaced colocando-a em um novo ALC. Os processos .NET começam com um único ALC padrão no qual todos os assemblies são carregados (exceto aqueles carregados com Assembly.LoadFile(string) e Assembly.Load(byte[])). Mas o processo pode criar e definir seus próprios ALCs personalizados com sua própria lógica de carregamento. Quando um assembly é carregado, o primeiro ALC em que ele é carregado é responsável por resolver suas dependências. Isso cria oportunidades para implementar poderosos mecanismos de carregamento de plug-ins .NET.

Em ambas as implementações, os assemblies são carregados preguiçosamente. Isso significa que eles são carregados quando um método que requer seu tipo é executado pela primeira vez.

Por exemplo, aqui estão duas versões do mesmo código que carregam uma dependência em momentos diferentes.

O primeiro sempre carrega sua dependência quando Program.GetRange() é chamado, porque a referência de dependência está lexicamente presente dentro do método:

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;
    }
}

O segundo carrega sua dependência somente se o limit parâmetro for 20 ou mais, devido à indireção interna através de um método:

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();
    }
}

Esta é uma boa prática, uma vez que minimiza a memória e a E/S do sistema de ficheiros e utiliza os recursos de forma mais eficiente. O infeliz efeito colateral disso é que não saberemos que o assembly não carrega até chegarmos ao caminho de código que tenta carregá-lo.

Ele também pode criar uma condição de tempo para conflitos de carga de montagem. Se duas partes do mesmo programa tentarem carregar versões diferentes do mesmo assembly, a versão carregada dependerá de qual caminho de código será executado primeiro.

Para o PowerShell, isso significa que os seguintes fatores podem afetar um conflito de carga de assembly:

  • Qual módulo foi carregado primeiro?
  • O caminho de código que usa a biblioteca de dependência foi executado?
  • O PowerShell carrega uma dependência conflitante na inicialização ou somente em determinados caminhos de código?

Correções rápidas e suas limitações

Em alguns casos, é possível fazer pequenos ajustes no seu módulo e corrigir as coisas com o mínimo de esforço. Mas essas soluções tendem a vir com ressalvas. Embora possam aplicar-se ao seu módulo, não funcionarão para todos os módulos.

Alterar a versão de dependência

A maneira mais simples de evitar conflitos de dependência é concordar com uma dependência. Isto pode ser possível quando:

  • Seu conflito é com uma dependência direta do seu módulo e você controla a versão.
  • Seu conflito é com uma dependência indireta, mas você pode configurar suas dependências diretas para usar uma versão de dependência indireta viável.
  • Você conhece a versão conflitante e pode confiar que ela não muda.

O pacote Newtonsoft.Json é um bom exemplo deste último cenário. Essa é uma dependência do PowerShell 6 e superior e não é usada no Windows PowerShell. Ou seja, uma maneira simples de resolver conflitos de versão é direcionar a versão mais baixa do Newtonsoft.Json para as versões do PowerShell que você deseja segmentar.

Por exemplo, o PowerShell 6.2.6 e o PowerShell 7.0.2 usam atualmente o Newtonsoft.Json versão 12.0.3. Para criar um módulo direcionado ao Windows PowerShell, PowerShell 6 e PowerShell 7, você deve direcionar Newtonsoft.Json 12.0.3 como uma dependência e incluí-lo em seu módulo criado. Quando o módulo é carregado no PowerShell 6 ou 7, o próprio assembly Newtonsoft.Json do PowerShell já está carregado. Uma vez que é a versão necessária para o seu módulo, a resolução é bem-sucedida. No Windows PowerShell, o assembly ainda não está presente no PowerShell, portanto, é carregado da pasta do módulo.

Geralmente, ao direcionar um pacote concreto do PowerShell, como Microsoft.PowerShell.Sdk ou System.Management.Automation, o NuGet deve ser capaz de resolver as versões de dependência corretas necessárias. O direcionamento para o Windows PowerShell e o PowerShell 6+ torna-se mais difícil porque você deve escolher entre direcionar várias estruturas ou PowerShellStandard.Library.

As circunstâncias em que a fixação a uma versão de dependência comum não funcionará incluem:

  • O conflito é com uma dependência indireta e nenhuma das suas dependências pode ser configurada para usar uma versão comum.
  • É provável que a outra versão de dependência mude com frequência, portanto, optar por uma versão comum é apenas uma solução de curto prazo.

Usar a dependência fora do processo

Esta solução é mais para usuários de módulos do que para autores de módulos. Esta é uma solução para usar quando confrontado com um módulo que não funcionará devido a um conflito de dependência existente.

Conflitos de dependência ocorrem porque duas versões do mesmo assembly são carregadas no mesmo processo .NET. Uma solução simples é carregá-los em processos diferentes, desde que você ainda possa usar a funcionalidade de ambos juntos.

No PowerShell, há várias maneiras de conseguir isso:

  • Invoque o PowerShell como um subprocesso

    Para executar um comando do PowerShell fora do processo atual, inicie um novo processo do PowerShell diretamente com a chamada de comando:

    pwsh -c 'Invoke-ConflictingCommand'
    

    A principal limitação aqui é que a reestruturação do resultado pode ser mais complicada ou mais propensa a erros do que outras opções.

  • O sistema de tarefas do PowerShell

    O sistema de trabalho do PowerShell também executa comandos fora do processo, enviando comandos para um novo processo do PowerShell e retornando os resultados:

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

    Neste caso, você só precisa ter certeza de que todas as variáveis e estado são passados corretamente.

    O sistema de trabalho também pode ser um pouco complicado ao executar pequenos comandos.

  • Comunicação remota do PowerShell

    Quando estiver disponível, a comunicação remota do PowerShell pode ser uma maneira útil de executar comandos fora do processo. Com a comunicação remota, você pode criar uma nova PSSession em um novo processo, chamar seus comandos sobre a comunicação remota do PowerShell e, em seguida, usar os resultados localmente com os outros módulos que contêm as dependências conflitantes.

    Um exemplo pode ser parecido com este:

    # 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
    
  • Comunicação remota implícita para o Windows PowerShell

    Outra opção no PowerShell 7 é usar o -UseWindowsPowerShell sinalizador em Import-Module. Isso importa o módulo por meio de uma sessão remota local para o Windows PowerShell:

    Import-Module -Name ConflictingModule -UseWindowsPowerShell
    

    Lembre-se de que os módulos podem não ser compatíveis ou funcionar de forma diferente com o Windows PowerShell.

Quando a invocação fora do processo não deve ser usada

Como um autor de módulo, a invocação de comando fora do processo é difícil de incorporar em um módulo e pode ter casos de borda que causam problemas. Em particular, a comunicação remota e os trabalhos podem não estar disponíveis em todos os ambientes onde o módulo precisa trabalhar. No entanto, o princípio geral de mover a implementação para fora do processo e permitir que o módulo PowerShell seja um cliente mais fino, ainda pode ser aplicável.

Como usuário do módulo, há casos em que a invocação fora do processo não funcionará:

  • Quando a comunicação remota do PowerShell não está disponível porque você não tem privilégios para usá-la ou ela não está habilitada.
  • Quando um determinado tipo .NET é necessário da saída como entrada para um método ou outro comando. Os comandos executados na comunicação remota do PowerShell emitem objetos desserializados em vez de objetos .NET fortemente tipados. Isso significa que chamadas de método e APIs fortemente tipadas não funcionam com a saída de comandos importados por comunicação remota.

Soluções mais robustas

Todas as soluções anteriores tinham cenários e módulos que não funcionam. No entanto, eles também têm a virtude de serem relativamente simples de implementar corretamente. As soluções a seguir são mais robustas, mas exigem mais esforço para implementar corretamente e podem introduzir bugs sutis se não forem escritas com cuidado.

Carregando através de contextos de carga de assembly do .NET Core

Os ALCs (Assembly Load Contexts) foram introduzidos no .NET Core 1.0 para abordar especificamente a necessidade de carregar várias versões do mesmo assembly no mesmo tempo de execução.

Dentro do .NET, eles oferecem a solução mais robusta para o problema de carregar versões conflitantes de um assembly. No entanto, ALCs personalizados não estão disponíveis no .NET Framework. Isso significa que essa solução só funciona no PowerShell 6 e superior.

Atualmente, o melhor exemplo de uso de um ALC para isolamento de dependência no PowerShell está no PowerShell Editor Services, o servidor de idiomas para a extensão do PowerShell para Visual Studio Code. Um ALC é usado para evitar que as próprias dependências dos Serviços de Editor do PowerShell entrem em conflito com as dos módulos do PowerShell.

Implementar o isolamento de dependência de módulo com um ALC é conceitualmente difícil, mas vamos trabalhar com um exemplo mínimo. Imagine que temos um módulo simples que se destina apenas a funcionar no PowerShell 7. O código-fonte está organizado da seguinte forma:

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

A implementação do cmdlet tem esta aparência:

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!");
        }
    }
}

O manifesto (fortemente simplificado) tem esta aparência:

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

E a csproj aparência é assim:

<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>

Quando construímos este módulo, a saída gerada tem o seguinte layout:

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

Neste exemplo, o nosso problema está na montagem, que é a Shared.Dependency.dll nossa dependência conflituosa imaginária. Esta é a dependência que precisamos colocar atrás de um ALC para que possamos usar a versão específica do módulo.

Precisamos reformular o módulo para que:

  • As dependências do módulo são carregadas apenas em nosso ALC personalizado, e não no ALC do PowerShell, portanto, não pode haver conflito. Além disso, à medida que adicionamos mais dependências ao nosso projeto, não queremos adicionar continuamente mais código para continuar carregando trabalhando. Em vez disso, queremos uma lógica de resolução de dependência reutilizável e genérica.
  • Carregar o módulo ainda funciona normalmente no PowerShell. Os cmdlets e outros tipos de que o sistema de módulo PowerShell precisa são definidos dentro do próprio ALC do PowerShell.

Para mediar esses dois requisitos, devemos dividir nosso módulo em duas montagens:

  • Um assembly de cmdlets, AlcModule.Cmdlets.dll, que contém definições de todos os tipos que o sistema de módulos do PowerShell precisa para carregar nosso módulo corretamente. Ou seja, quaisquer implementações da classe base e a classe que implementa IModuleAssemblyInitializer, que configura o manipulador de eventos para AssemblyLoadContext.Default.Resolving carregar AlcModule.Engine.dll corretamente através de Cmdlet nosso ALC personalizado. Como o PowerShell 7 oculta deliberadamente tipos definidos em assemblies carregados em outros ALCs, todos os tipos que devem ser expostos publicamente ao PowerShell também devem ser definidos aqui. Finalmente, nossa definição de ALC personalizada precisa ser definida nesta montagem. Além disso, o mínimo de código possível deve viver nesta assembleia.
  • Um conjunto de motor, AlcModule.Engine.dll, que lida com a implementação real do módulo. Os tipos disso estão disponíveis no ALC do PowerShell, mas ele é inicialmente carregado por meio do nosso ALC personalizado. Suas dependências são carregadas apenas no ALC personalizado. Efetivamente, isso se torna uma ponte entre os dois ALCs.

Usando este conceito de ponte, nossa nova situação de montagem é assim:

Diagrama que representa AlcModule.Engine.dll a ponte entre os dois ALCs

Para garantir que a lógica de sondagem de dependência do ALC padrão não resolva as dependências a serem carregadas no ALC personalizado, precisamos separar essas duas partes do módulo em diretórios diferentes. O novo layout do módulo tem a seguinte estrutura:

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

Para ver como a implementação muda, começaremos com a implementação de AlcModule.Engine.dll:

using Shared.Dependency;

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

Este é um contêiner simples para a dependência, Shared.Dependency.dllmas você deve pensar nele como a API .NET para sua funcionalidade que os cmdlets no outro assembly encapsulam para PowerShell.

O cmdlet em AlcModule.Cmdlets.dll tem esta aparência:

// 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!");
        }
    }
}

Neste ponto, se fôssemos carregar AlcModule e executar Test-AlcModule, obteremos um FileNotFoundException quando o ALC padrão tenta carregar Alc.Engine.dll para executar EndProcessing(). Isso é bom, pois significa que o ALC padrão não pode encontrar as dependências que queremos ocultar.

Agora precisamos adicionar código para AlcModule.Cmdlets.dll que ele saiba como resolver AlcModule.Engine.dll. Primeiro, devemos definir nosso ALC personalizado para resolver assemblies do diretório do Dependencies nosso módulo:

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;
        }
    }
}

Em seguida, precisamos conectar nosso ALC personalizado ao evento padrão do Resolving ALC, que é a versão ALC do AssemblyResolve evento em Domínios de Aplicativo. Este evento é disparado para encontrar AlcModule.Engine.dll quando EndProcessing() é chamado.

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 are 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);
        }
    }
}

Com a nova implementação, dê uma olhada na sequência de chamadas que ocorre quando o módulo é carregado e Test-AlcModule executado:

Diagrama de sequência de chamadas usando o ALC personalizado para carregar dependências

Alguns pontos de interesse são:

  • O IModuleAssemblyInitializer é executado primeiro quando o módulo carrega e define o Resolving evento.
  • Não carregamos as dependências até Test-AlcModule que seja executado e seu EndProcessing() método seja chamado.
  • Quando EndProcessing() é chamado, o ALC padrão não consegue localizar AlcModule.Engine.dll e dispara o Resolving evento.
  • Nosso manipulador de eventos conecta o ALC personalizado ao ALC padrão e carrega AlcModule.Engine.dll somente.
  • Quando AlcEngine.Use() é chamado dentro AlcModule.Engine.dll, o ALC personalizado novamente entra em ação para resolver Shared.Dependency.dll. Especificamente, ele sempre carrega o nossoShared.Dependency.dll , uma vez que nunca entra em conflito com nada no ALC padrão e só olha em nosso Dependencies diretório.

Montando a implementação, nosso novo layout de código-fonte tem esta aparência:

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

AlcModule.Cmdlets.csproj tem a seguinte aparência:

<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 tem esta aparência:

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

Assim, quando construímos o módulo, a nossa estratégia é:

  • Construir AlcModule.Engine
  • Construir AlcModule.Cmdlets
  • Copie tudo para o Dependencies diretório e lembre-se do AlcModule.Engine que copiamos
  • Copie tudo AlcModule.Cmdlets do que não estava no AlcModule.Engine diretório do módulo base

Como o layout do módulo aqui é tão crucial para a separação de dependência, aqui está um script de construção para usar a partir da raiz de origem:

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 }

Finalmente, temos uma maneira geral de isolar as dependências do nosso módulo em um contexto de carga de assembly que permanece robusto ao longo do tempo à medida que mais dependências são adicionadas.

Para obter um exemplo mais detalhado, vá para este repositório GitHub. Este exemplo demonstra como migrar um módulo para usar um ALC, mantendo esse módulo funcionando no .NET Framework. Ele também mostra como usar o .NET Standard e o PowerShell Standard para simplificar a implementação principal.

Essa solução também é usada pelo módulo Bicep PowerShell, e a postagem do blog Resolvendo conflitos do módulo PowerShell é outra boa leitura sobre essa solução.

Manipulador de resolução de montagem para carregamento lado a lado

Embora seja robusta, a solução descrita acima requer que o assembly do módulo não faça referência direta aos assemblies de dependência, mas, em vez disso, faça referência a um assembly wrapper que faça referência aos assemblies de dependência. O assembly wrapper age como uma ponte, encaminhando as chamadas do assembly do módulo para os assemblies de dependência. Isso faz com que geralmente seja uma quantidade não trivial de trabalho para adotar esta solução:

  • Para um novo módulo, isso aumentaria a complexidade do projeto e da implementação
  • Para um módulo existente, isso exigiria uma refatoração significativa

Há uma solução simplificada para obter o carregamento de montagem lado a lado, conectando um Resolving evento com uma instância personalizada AssemblyLoadContext . Usar este método é mais fácil para o autor do módulo, mas tem duas limitações. Confira o repositório PowerShell-ALC-Samples para obter código de exemplo e documentação que descreve essas limitações e cenários detalhados para esta solução.

Importante

Não use Assembly.LoadFile para a finalidade de isolamento de dependência. Usar Assembly.LoadFile cria um problema de identidade de tipo quando outro módulo carrega uma versão diferente do mesmo assembly no padrão AssemblyLoadContext. Enquanto essa API carrega um assembly em uma instância separadaAssemblyLoadContext, os assemblies carregados podem ser descobertos pelo código de resolução de tipo do PowerShell. Portanto, pode haver tipos duplicados com o mesmo nome de tipo totalmente qualifed disponíveis a partir de dois ALCs diferentes.

Domínios de aplicativos personalizados

A opção final e mais extrema para o isolamento de assembly é usar domínios de aplicativo personalizados. Os Domínios de Aplicação só estão disponíveis no .NET Framework. Eles são usados para fornecer isolamento em processo entre partes de um aplicativo .NET. Um dos usos é isolar cargas de montagem umas das outras dentro do mesmo processo.

No entanto, os domíniosde aplicativo são limites de serialização. Os objetos em um domínio de aplicativo não podem ser referenciados e usados diretamente por objetos em outro domínio de aplicativo. Você pode contornar isso implementando MarshalByRefObjecto . Mas quando você não controla os tipos, como geralmente é o caso das dependências, não é possível forçar uma implementação aqui. A única solução é fazer grandes mudanças arquitetônicas. O limite de serialização também tem sérias implicações de desempenho.

Como os Domínios de Aplicativo têm essa séria limitação, são complicados de implementar e só funcionam no .NET Framework, não daremos um exemplo de como você pode usá-los aqui. Embora valha a pena mencioná-los como uma possibilidade, eles não são recomendados.

Se você estiver interessado em tentar usar um domínio de aplicativo personalizado, os links a seguir podem ajudar:

Soluções para conflitos de dependência que não funcionam para o PowerShell

Por fim, abordaremos algumas possibilidades que surgem ao pesquisar conflitos de dependência do .NET no .NET que podem parecer promissoras, mas geralmente não funcionam para o PowerShell.

Essas soluções têm o tema comum de que são alterações nas configurações de implantação para um ambiente onde você controla o aplicativo e, possivelmente, toda a máquina. Essas soluções são orientadas para cenários como servidores Web e outros aplicativos implantados em ambientes de servidor, onde o ambiente se destina a hospedar o aplicativo e é livre para ser configurado pelo usuário de implantação. Eles também tendem a ser muito orientados para o .NET Framework, o que significa que não funcionam com o PowerShell 6 ou superior.

Se você souber que seu módulo só é usado em ambientes do Windows PowerShell 5.1 sobre os quais você tem controle total, algumas dessas opções podem ser opções. Em geral, no entanto, os módulos não devem modificar o estado global da máquina assim. Ele pode interromper configurações que causam problemas em powershell.exe, outros módulos ou outros aplicativos dependentes que fazem com que o módulo falhe de maneiras inesperadas.

Redirecionamento de vinculação estática com app.config para forçar o uso da mesma versão de dependência

Os aplicativos do .NET Framework podem aproveitar um app.config arquivo para configurar alguns comportamentos de aplicativo declarativamente. É possível escrever uma app.config entrada que configure a vinculação de assembly para redirecionar o carregamento de assembly para uma versão específica.

Dois problemas com isso para o PowerShell são:

  • O .NET Core não suporta app.config, pelo que esta solução só se aplica ao powershell.exe.
  • powershell.exe é um aplicativo compartilhado que vive no System32 diretório. É provável que seu módulo não seja capaz de modificar seu conteúdo em muitos sistemas. Mesmo que possa, modificar o app.config poderia quebrar uma configuração existente ou afetar o carregamento de outros módulos.

Configuração codebase com app.config

Pelos mesmos motivos, tentar definir a codebase configuração não app.config funcionará nos módulos do PowerShell.

Instalando dependências no GAC (Global Assembly Cache)

Outra maneira de resolver conflitos de versão de dependência no .NET Framework é instalar dependências no GAC, para que diferentes versões possam ser carregadas lado a lado a partir do GAC.

Novamente, para os módulos do PowerShell, os principais problemas aqui são:

  • O GAC só se aplica ao .NET Framework, portanto, isso não ajuda no PowerShell 6 e acima.
  • A instalação de montagens no GAC é uma modificação do estado global da máquina e pode causar efeitos colaterais em outros aplicativos ou em outros módulos. Também pode ser difícil fazer corretamente, mesmo quando o módulo tem os privilégios de acesso necessários. Errar pode causar sérios problemas em toda a máquina em outros aplicativos .NET.

Leitura adicional

Há muito mais para ler sobre conflitos de dependência de versão de assembly .NET. Aqui estão alguns bons pontos de salto: