Rozwiązywanie konfliktów zależności zestawu modułu programu PowerShell

Podczas pisania binarnego modułu programu PowerShell w języku C# naturalne jest podjęcie zależności od innych pakietów lub bibliotek w celu zapewnienia funkcjonalności. Pobieranie zależności od innych bibliotek jest pożądane w przypadku ponownego użycia kodu. Program PowerShell zawsze ładuje zestawy do tego samego kontekstu. W ten sposób występują problemy, gdy zależności modułu powodują konflikt z już załadowanymi bibliotekami DLL i mogą uniemożliwić używanie dwóch w inny sposób niepowiązanych modułów w tej samej sesji programu PowerShell.

Jeśli wystąpił ten problem, został wyświetlony komunikat o błędzie podobny do następującego:

Komunikat o błędzie powodujący konflikt obciążenia zestawu

W tym artykule omówiono niektóre sposoby występowania konfliktów zależności w programie PowerShell i sposoby rozwiązywania problemów z konfliktami zależności. Nawet jeśli nie jesteś autorem modułu, w tym miejscu istnieją pewne wskazówki, które mogą pomóc w przypadku konfliktów zależności występujących w modułach, których używasz.

Dlaczego występują konflikty zależności?

Na platformie .NET konflikty zależności występują, gdy dwie wersje tego samego zestawu są ładowane do tego samego kontekstu ładowania zestawów. Ten termin oznacza nieco inne elementy na różnych platformach .NET, które zostały omówione w dalszej części tego artykułu. Ten konflikt jest typowym problemem występującym w dowolnym oprogramowaniu, w którym są używane zależności w wersji.

Problemy powodujące konflikty są złożone przez fakt, że projekt prawie nigdy celowo lub bezpośrednio nie zależy od dwóch wersji tej samej zależności. Zamiast tego projekt ma co najmniej dwie zależności, które wymagają innej wersji tej samej zależności.

Załóżmy na przykład, że aplikacja .NET, DuckBuilder, wprowadza dwie zależności, aby wykonać części jej funkcji i wygląda następująco:

Dwie zależności DuckBuilder polegają na różnych wersjach pliku Newtonsoft.Json

Ponieważ Contoso.ZipTools obie te Fabrikam.FileHelpers elementy zależą od różnych wersji pliku Newtonsoft.Json, może występować konflikt zależności w zależności od sposobu ładowania poszczególnych zależności.

Konflikt z zależnościami programu PowerShell

W programie PowerShell problem z konfliktem zależności jest powiększony, ponieważ własne zależności programu PowerShell są ładowane do tego samego kontekstu udostępnionego. Oznacza to, że aparat programu PowerShell i wszystkie załadowane moduły programu PowerShell nie mogą mieć zależności powodujące konflikt. Klasycznym przykładem tego jest plik Newtonsoft.Json:

Moduł FictionalTools zależy od nowszej wersji pliku Newtonsoft.Json niż program PowerShell

W tym przykładzie moduł FictionalTools zależy od wersji 12.0.3Newtonsoft.Json, która jest nowszą wersją pliku Newtonsoft.Json niż 11.0.2 jest dostarczana w przykładowym programie PowerShell.

Uwaga

Jest to przykład. Program PowerShell 7.0 jest obecnie dostarczany z plikiem Newtonsoft.Json 12.0.3. Nowsze wersje programu PowerShell mają nowsze wersje pliku Newtonsoft.Json.

Ponieważ moduł zależy od nowszej wersji zestawu, nie zaakceptuje wersji, którą program PowerShell już załadował. Ponieważ program PowerShell załadował już wersję zestawu, moduł nie może załadować własnej wersji przy użyciu konwencjonalnego mechanizmu ładowania.

Konflikt z zależnościami innego modułu

Innym typowym scenariuszem w programie PowerShell jest załadowanie modułu zależnego od jednej wersji zestawu, a następnie załadowanie innego modułu później, który zależy od innej wersji tego zestawu.

Często wygląda to następująco:

Dwa moduły programu PowerShell wymagają różnych wersji zależności Microsoft.Extensions.Logging

W takim przypadku FictionalTools moduł wymaga nowszej Microsoft.Extensions.Logging wersji modułu FilesystemManager niż moduł.

Wyobraź sobie, że te moduły ładują zależności, umieszczając zestawy zależności w tym samym katalogu co zestaw modułu głównego. Dzięki temu platforma .NET może niejawnie ładować je według nazwy. Jeśli używamy programu PowerShell 7.0 (na platformie .NET Core 3.1), możemy załadować i uruchomić polecenie , a następnie załadować i uruchomić FictionalToolsFilesystemManager bez problemu. Jednak w nowej sesji, jeśli załadujemy i uruchomimy FilesystemManagerFileLoadExceptionFictionalTools polecenie , załadujemy FictionalToolspolecenie , ponieważ wymaga nowszej Microsoft.Extensions.Logging wersji niż załadowany. FictionalTools Nie można załadować wymaganej wersji, ponieważ zestaw o tej samej nazwie został już załadowany.

Program PowerShell i platforma .NET

Program PowerShell działa na platformie .NET, która jest odpowiedzialna za rozpoznawanie i ładowanie zależności zestawu. Musimy zrozumieć, jak platforma .NET działa tutaj, aby zrozumieć konflikty zależności.

Musimy również zmierzyć się z faktem, że różne wersje programu PowerShell działają w różnych implementacjach platformy .NET. Ogólnie rzecz biorąc, program PowerShell 5.1 lub nowszy działa w programie .NET Framework, podczas gdy program PowerShell 6 i nowsze są uruchamiane na platformie .NET Core. Te dwie implementacje ładowania platformy .NET i obsługują zestawy inaczej. Oznacza to, że rozwiązywanie konfliktów zależności może się różnić w zależności od bazowej platformy .NET.

Konteksty ładowania zestawów

Na platformie .NET kontekst ładowania zestawów (ALC) to przestrzeń nazw środowiska uruchomieniowego, w której są ładowane zestawy. Nazwy zestawów muszą być unikatowe. Ta koncepcja umożliwia unikatowe rozpoznawanie zestawów według nazwy w każdym ALC.

Ładowanie odwołań do zestawów na platformie .NET

Semantyka ładowania zestawu zależy od implementacji platformy .NET (.NET Core i .NET Framework) oraz interfejsu API platformy .NET używanego do ładowania określonego zestawu. Zamiast szczegółowo tutaj znaleźć linki w sekcji Dalsze czytanie , które zawierają szczegółowe informacje na temat sposobu działania ładowania zestawów platformy .NET w każdej implementacji platformy .NET.

W tym artykule odwołujemy się do następujących mechanizmów:

  • Niejawne ładowanie zestawu (skutecznie Assembly.Load(AssemblyName)), gdy platforma .NET niejawnie próbuje załadować zestaw według nazwy z statycznego odwołania do zestawu w kodzie platformy .NET.
  • Assembly.LoadFrom(), interfejs API ładowania zorientowanego na wtyczkę, który dodaje programy obsługi w celu rozpoznawania zależności załadowanej biblioteki DLL. Ta metoda może nie rozpoznawać zależności w żądany sposób.
  • Assembly.LoadFile(), podstawowy interfejs API ładowania przeznaczony do załadowania tylko zestawu, dla którego został wyświetlony monit i nie obsługuje żadnych zależności.

Różnice w programie .NET Framework a .NET Core

Sposób działania tych interfejsów API zmienił się w subtelny sposób między platformami .NET Core i .NET Framework, więc warto przeczytać zawarte linki. Co ważne, konteksty ładowania zestawów i inne mechanizmy rozwiązywania zestawów zmieniły się między programem .NET Framework i platformą .NET Core.

W szczególności program .NET Framework ma następujące funkcje:

  • GlobalNa pamięć podręczna zestawów do rozpoznawania zestawów dla całego komputera
  • Domeny aplikacji, które działają jak piaskownice procesów w celu izolacji zestawu, ale także przedstawiają warstwę serializacji, z którą można się zmierzyć
  • Ograniczony model kontekstu obciążenia zestawu, który ma stały zestaw kontekstów ładowania zestawów, z których każdy ma własne zachowanie:
    • Domyślny kontekst ładowania, w którym zestawy są domyślnie ładowane
    • Ładowanie z kontekstu do ręcznego ładowania zestawów w czasie wykonywania
    • Kontekst tylko odbicia w celu bezpiecznego ładowania zestawów do odczytywania metadanych bez ich uruchamiania
    • Tajemnicza pustka, z którą zestawy ładowane Assembly.LoadFile(string path) i Assembly.Load(byte[] asmBytes) żyją

Aby uzyskać więcej informacji, zobacz Najlepsze rozwiązania dotyczące ładowania zestawów.

Platforma .NET Core (i platforma .NET 5+) zastąpiła tę złożoność prostszym modelem:

  • Brak globalnej pamięci podręcznej zestawów. Aplikacje przynoszą wszystkie własne zależności. Spowoduje to usunięcie czynnika zewnętrznego do rozwiązywania zależności w aplikacjach, dzięki czemu rozpoznawanie zależności będzie bardziej powtarzalne. Program PowerShell, jako host wtyczki, komplikuje to nieco w przypadku modułów. Jego zależności w programie $PSHOME są współużytkowane ze wszystkimi modułami.
  • Tylko jedna domena aplikacji i brak możliwości tworzenia nowych. Koncepcja domeny aplikacji jest utrzymywana na platformie .NET jako globalny stan procesu .NET.
  • Nowy, rozszerzalny model kontekstu ładowania zestawów (ALC). Rozpoznawanie zestawów może być przestrzeń nazw, umieszczając ją w nowym ALC. Procesy platformy .NET zaczynają się od pojedynczego domyślnego ALC, do którego są ładowane wszystkie zestawy (z wyjątkiem tych załadowanych z elementami Assembly.LoadFile(string) i Assembly.Load(byte[])). Jednak proces może tworzyć i definiować własne niestandardowe kontrolery ALC przy użyciu własnej logiki ładowania. Po załadowaniu zestawu pierwszy element ALC, do niego załadowany, jest odpowiedzialny za rozpoznawanie jego zależności. Dzięki temu można zaimplementować zaawansowane mechanizmy ładowania wtyczek platformy .NET.

W obu implementacjach zestawy są ładowane leniwie. Oznacza to, że są ładowane po pierwszym uruchomieniu metody wymagającej ich typu.

Na przykład poniżej przedstawiono dwie wersje tego samego kodu, które ładują zależność w różnym czasie.

Pierwszy zawsze ładuje zależność, gdy Program.GetRange() jest wywoływana, ponieważ odwołanie zależności jest leksykalnie obecne w metodzie:

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

Drugi ładuje zależność tylko wtedy, gdy limit parametr ma wartość 20 lub więcej, ze względu na wewnętrzną pośredniość za pomocą metody:

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

Jest to dobra praktyka, ponieważ minimalizuje pamięć i operacje we/wy systemu plików i wykorzystują zasoby wydajniej. Niefortunny efekt uboczny jest taki, że nie będziemy wiedzieć, że nie można załadować zestawu, dopóki nie osiągniemy ścieżki kodu, która próbuje załadować zestaw.

Może również utworzyć warunek chronometrażu dla konfliktów obciążenia zestawu. Jeśli dwie części tego samego programu spróbują załadować różne wersje tego samego zestawu, załadowana wersja zależy od tego, która ścieżka kodu jest uruchamiana jako pierwsza.

W przypadku programu PowerShell oznacza to, że następujące czynniki mogą mieć wpływ na konflikt obciążenia zestawu:

  • Który moduł został załadowany jako pierwszy?
  • Czy ścieżka kodu korzystająca z biblioteki zależności została uruchomiona?
  • Czy program PowerShell ładuje zależność powodującą konflikt podczas uruchamiania lub tylko w ramach określonych ścieżek kodu?

Szybkie poprawki i ich ograniczenia

W niektórych przypadkach można wprowadzić niewielkie korekty w module i naprawić elementy przy minimalnym nakładzie pracy. Jednak te rozwiązania mają tendencję do tworzenia zastrzeżeń. Chociaż mogą one dotyczyć modułu, nie będą działać dla każdego modułu.

Zmienianie wersji zależności

Najprostszym sposobem uniknięcia konfliktów zależności jest uzgodnienie zależności. Może to być możliwe w następujących przypadkach:

  • Twój konflikt jest bezpośrednią zależnością modułu i kontrolujesz wersję.
  • Konflikt dotyczy zależności pośredniej, ale możesz skonfigurować bezpośrednie zależności tak, aby korzystały z wersji zależności pośredniej z możliwością działania.
  • Znasz wersję powodującą konflikt i możesz polegać na niej, że nie zmienia się.

Pakiet Newtonsoft.Json jest dobrym przykładem tego ostatniego scenariusza. Jest to zależność programu PowerShell 6 lub nowszego i nie jest używana w programie Windows PowerShell. Oznacza to, że prostym sposobem rozwiązywania konfliktów wersji jest skierowanie do najniższej wersji pliku Newtonsoft.Json w wersjach programu PowerShell, które chcesz kierować.

Na przykład program PowerShell 6.2.6 i program PowerShell 7.0.2 obecnie używają polecenia Newtonsoft.Json w wersji 12.0.3. Aby utworzyć moduł przeznaczony dla programu Windows PowerShell, programu PowerShell 6 i programu PowerShell 7, należy kierować element docelowy Newtonsoft.Json 12.0.3 jako zależność i dołączyć go do wbudowanego modułu. Po załadowaniu modułu w programie PowerShell 6 lub 7 jest już załadowany własny zestaw Programu PowerShell Newtonsoft.Json . Ponieważ jest to wersja wymagana dla modułu, rozwiązanie powiedzie się. W programie Windows PowerShell zestaw nie jest jeszcze obecny w programie PowerShell, więc zamiast tego jest ładowany z folderu modułu.

Ogólnie rzecz biorąc, w przypadku określania konkretnego pakietu programu PowerShell, takiego jak Microsoft.PowerShell.Sdk lub System.Management.Automation, pakiet NuGet powinien mieć możliwość rozwiązania odpowiednich wymaganych wersji zależności. Określanie wartości docelowych zarówno programu Windows PowerShell, jak i programu PowerShell 6+ staje się trudniejsze, ponieważ należy wybrać między określaniem wartości docelowej dla wielu struktur lub biblioteki PowerShellStandard.Library.

Okoliczności, w których przypinanie do typowej wersji zależności nie będzie działać:

  • Konflikt dotyczy zależności pośredniej, a żadna z zależności nie może być skonfigurowana do używania wspólnej wersji.
  • Inna wersja zależności może się często zmienić, więc osiedlenie się na wspólnej wersji jest tylko krótkoterminową poprawką.

Używanie zależności poza procesem

To rozwiązanie jest bardziej przeznaczone dla użytkowników modułów niż autorzy modułów. Jest to rozwiązanie używane podczas konfrontacji z modułem, który nie będzie działać z powodu istniejącego konfliktu zależności.

Konflikty zależności występują, ponieważ dwie wersje tego samego zestawu są ładowane do tego samego procesu platformy .NET. Prostym rozwiązaniem jest załadowanie ich do różnych procesów, o ile nadal można używać funkcji z obu tych procesów.

W programie PowerShell istnieje kilka sposobów, aby to osiągnąć:

  • Wywoływanie programu PowerShell jako podprocesu

    Aby uruchomić polecenie programu PowerShell poza bieżącym procesem, uruchom nowy proces programu PowerShell bezpośrednio za pomocą wywołania polecenia:

    pwsh -c 'Invoke-ConflictingCommand'
    

    Głównym ograniczeniem jest to, że restrukturyzacja wyniku może być trudniejsza lub bardziej podatna na błędy niż inne opcje.

  • System zadań programu PowerShell

    System zadań programu PowerShell uruchamia również polecenia poza procesem, wysyłając polecenia do nowego procesu programu PowerShell i zwracając wyniki:

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

    W takim przypadku wystarczy upewnić się, że wszystkie zmienne i stan są prawidłowo przekazywane.

    System zadań może być również nieco kłopotliwy podczas uruchamiania małych poleceń.

  • Komunikacja zdalna programu PowerShell

    Gdy jest dostępna, komunikacja zdalna programu PowerShell może być przydatnym sposobem uruchamiania poleceń poza procesem. Za pomocą komunikacji zdalnej możesz utworzyć nową usługę PSSession w nowym procesie, wywołać polecenia za pośrednictwem komunikacji zdalnej programu PowerShell, a następnie użyć wyników lokalnie z innymi modułami zawierającymi zależności powodujące konflikt.

    Przykład może wyglądać następująco:

    # 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
    
  • Niejawna komunikacja zdalna z programem Windows PowerShell

    Inną opcją w programie PowerShell 7 jest użycie flagi -UseWindowsPowerShell na .Import-Module Spowoduje to zaimportowanie modułu za pośrednictwem lokalnej sesji komunikacji zdalnej do programu Windows PowerShell:

    Import-Module -Name ConflictingModule -UseWindowsPowerShell
    

    Należy pamiętać, że moduły mogą nie być zgodne z programem Lub mogą działać inaczej w programie Windows PowerShell.

W przypadku wywołania poza procesem nie należy używać

Jako autor modułu wywołanie polecenia poza procesem jest trudne do pieczenia w module i może mieć przypadki brzegowe, które powodują problemy. W szczególności komunikacja zdalna i zadania mogą nie być dostępne we wszystkich środowiskach, w których moduł musi działać. Jednak ogólna zasada przenoszenia implementacji poza proces i umożliwienie modułu programu PowerShell być klientem cieńszym, może nadal mieć zastosowanie.

Jako użytkownik modułu istnieją przypadki, w których wywołanie poza procesem nie będzie działać:

  • Gdy komunikacja zdalna programu PowerShell jest niedostępna, ponieważ nie masz uprawnień do jej używania lub nie jest włączona.
  • Gdy określony typ platformy .NET jest wymagany z danych wyjściowych jako dane wejściowe do metody lub innego polecenia. Polecenia uruchamiane za pośrednictwem komunikacji zdalnej programu PowerShell emitują obiekty deserializowane, a nie silnie typizowane obiekty platformy .NET. Oznacza to, że wywołania metod i silnie typizowane interfejsy API nie działają z danymi wyjściowymi poleceń zaimportowanych za pośrednictwem komunikacji telefonicznej.

Bardziej niezawodne rozwiązania

Poprzednie rozwiązania miały wszystkie scenariusze i moduły, które nie działają. Jednak mają one również zaletę stosunkowo prostego wdrożenia. Poniższe rozwiązania są bardziej niezawodne, ale wymagają większego nakładu pracy w celu poprawnego zaimplementowania i mogą wprowadzać subtelne błędy, jeśli nie zostały starannie napisane.

Ładowanie za pośrednictwem kontekstów ładowania zestawów platformy .NET Core

Konteksty ładowania zestawów (ALC) zostały wprowadzone na platformie .NET Core 1.0, aby w szczególności rozwiązać konieczność załadowania wielu wersji tego samego zestawu do tego samego środowiska uruchomieniowego.

Na platformie .NET oferują najbardziej niezawodne rozwiązanie problemu ładowania wersji zestawu powodującego konflikt. Niestandardowe kontrolery ALC nie są jednak dostępne w programie .NET Framework. Oznacza to, że to rozwiązanie działa tylko w programie PowerShell 6 lub nowszym.

Obecnie najlepszym przykładem użycia usługi ALC na potrzeby izolacji zależności w programie PowerShell są usługi edytora programu PowerShell, serwer językowy rozszerzenia programu PowerShell dla programu Visual Studio Code. Usługa ALC służy do zapobiegania starciu własnych zależności usług Edytora programu PowerShell z tymi w modułach programu PowerShell.

Implementowanie izolacji zależności modułu za pomocą usługi ALC jest koncepcyjnie trudne, ale będziemy pracować z minimalnym przykładem. Załóżmy, że mamy prosty moduł przeznaczony tylko do pracy w programie PowerShell 7. Kod źródłowy jest zorganizowany w następujący sposób:

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

Implementacja polecenia cmdlet wygląda następująco:

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

Manifest (mocno uproszczony) wygląda następująco:

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

I wygląda następująco 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>

Podczas kompilowanie tego modułu wygenerowane dane wyjściowe mają następujący układ:

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

W tym przykładzie Shared.Dependency.dll nasz problem znajduje się w zestawie, który jest naszą wyimaginowaną zależnością powodującą konflikt. Jest to zależność, którą musimy umieścić za usługą ALC, aby można było użyć wersji specyficznej dla modułu.

Musimy ponownie zaprojektować moduł, aby:

  • Zależności modułów są ładowane tylko do niestandardowego ALC, a nie do usługi ALC programu PowerShell, więc nie może występować konflikt. Ponadto w miarę dodawania większej liczby zależności do naszego projektu nie chcemy stale dodawać więcej kodu, aby kontynuować ładowanie. Zamiast tego chcemy używać logiki rozpoznawania zależności wielokrotnego użytku.
  • Ładowanie modułu nadal działa normalnie w programie PowerShell. Polecenia cmdlet i inne typy, których wymaga system modułu programu PowerShell, są definiowane we własnym ALC programu PowerShell.

Aby pośrednicować w tych dwóch wymaganiach, musimy podzielić moduł na dwa zestawy:

  • Zestaw poleceń cmdlet , AlcModule.Cmdlets.dllktóry zawiera definicje wszystkich typów, które system modułów programu PowerShell musi poprawnie załadować nasz moduł. Mianowicie wszystkie implementacje klasy bazowej Cmdlet i klasy, która implementuje program , który konfiguruje IModuleAssemblyInitializerprogram obsługi AssemblyLoadContext.Default.Resolving zdarzeń, aby prawidłowo załadować AlcModule.Engine.dll za pośrednictwem niestandardowego ALC. Ponieważ program PowerShell 7 celowo ukrywa typy zdefiniowane w zestawach załadowanych w innych bibliotekach ALC, wszystkie typy, które mają być publicznie widoczne dla programu PowerShell, muszą być również zdefiniowane w tym miejscu. Na koniec nasza niestandardowa definicja ALC musi być zdefiniowana w tym zestawie. Poza tym tak mało kodu, jak to możliwe, powinno istnieć w tym zestawie.
  • Zestaw aparatu, AlcModule.Engine.dll, który obsługuje rzeczywistą implementację modułu. Typy z tego typu są dostępne w usłudze PowerShell ALC, ale są one początkowo ładowane za pośrednictwem naszego niestandardowego ALC. Jego zależności są ładowane tylko do niestandardowego ALC. W rzeczywistości staje się to mostem między dwoma ALC.

Korzystając z tej koncepcji mostu, nasza nowa sytuacja montażu wygląda następująco:

Diagram przedstawiający AlcModule.Engine.dll mostkowanie dwóch kontrolerów ALC

Aby upewnić się, że domyślna logika sondowania zależności usługi ALC nie rozwiązuje problemów z zależnościami, które mają zostać załadowane do niestandardowego zestawu ALC, musimy oddzielić te dwie części modułu w różnych katalogach. Nowy układ modułu ma następującą strukturę:

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

Aby zobaczyć, jak zmienia się implementacja, zaczniemy od implementacji polecenia AlcModule.Engine.dll:

using Shared.Dependency;

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

Jest to prosty kontener dla zależności , Shared.Dependency.dllale należy traktować go jako interfejs API platformy .NET dla funkcji, które polecenia cmdlet w innym zestawie zawijają dla programu PowerShell.

Polecenie cmdlet w pliku AlcModule.Cmdlets.dll wygląda następująco:

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

W tym momencie, jeśli mieliśmy załadować moduł AlcModule i uruchomić Test-AlcModulepolecenie , otrzymujemy wyjątek FileNotFoundException, gdy domyślna aplikacja ALC próbuje załadować Alc.Engine.dll polecenie , aby uruchomić polecenie EndProcessing(). Jest to dobre, ponieważ oznacza to, że domyślna aplikacja ALC nie może odnaleźć zależności, które chcemy ukryć.

Teraz musimy dodać kod, AlcModule.Cmdlets.dll aby wiedzieć, jak rozwiązać problem AlcModule.Engine.dll. Najpierw musimy zdefiniować niestandardowy element ALC, aby rozwiązać problemy z zestawami z katalogu naszego modułu 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;
        }
    }
}

Następnie musimy podłączyć nasze niestandardowe ALC do domyślnego zdarzenia ALC, które jest wersją AssemblyResolve ALC Resolving zdarzenia w domenach aplikacji. To zdarzenie jest uruchamiane, aby znaleźć AlcModule.Engine.dll , kiedy EndProcessing() jest wywoływany.

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

W przypadku nowej implementacji przyjrzyj się sekwencji wywołań, które występują po załadowaniu modułu i Test-AlcModule uruchomieniu:

Diagram sekwencji wywołań przy użyciu niestandardowego ALC do ładowania zależności

Niektóre punkty orientacyjne to:

  • Element IModuleAssemblyInitializer jest uruchamiany jako pierwszy, gdy moduł ładuje i ustawia Resolving zdarzenie.
  • Nie ładujemy zależności, dopóki Test-AlcModule nie zostanie uruchomiona, a jej EndProcessing() metoda zostanie wywołana.
  • Po EndProcessing() wywołaniu domyślny element ALC nie może odnaleźć AlcModule.Engine.dll zdarzenia i go wyzwolić Resolving .
  • Nasza procedura obsługi zdarzeń podłącza niestandardowy element ALC do domyślnego ALC i ładuje AlcModule.Engine.dll tylko.
  • Gdy AlcEngine.Use() element jest wywoływany w programie AlcModule.Engine.dll, niestandardowy kod ALC jest ponownie uruchamiany w celu rozwiązania problemu Shared.Dependency.dll. W szczególności zawsze ładuje naszeShared.Dependency.dll , ponieważ nigdy nie powoduje konfliktów z niczym w domyślnym ALC i wygląda tylko w naszym Dependencies katalogu.

Zestaw implementacji nowy układ kodu źródłowego wygląda następująco:

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

Plik AlcModule.Cmdlets.csproj wygląda następująco:

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

Plik AlcModule.Engine.csproj wygląda następująco:

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

Dlatego podczas tworzenia modułu nasza strategia to:

  • Budować AlcModule.Engine
  • Budować AlcModule.Cmdlets
  • Skopiuj wszystko z AlcModule.Engine katalogu Dependencies i zapamiętaj skopiowane elementy
  • Skopiuj wszystko, z AlcModule.Cmdlets czego nie było w AlcModule.Engine katalogu podstawowym modułu

Ponieważ układ modułu w tym miejscu ma kluczowe znaczenie dla separacji zależności, oto skrypt kompilacji do użycia z katalogu głównego źródła:

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 }

Na koniec mamy ogólny sposób izolowania zależności modułu w kontekście ładowania zestawu, który pozostaje niezawodny w miarę dodawania większej liczby zależności.

Aby uzyskać bardziej szczegółowy przykład, przejdź do tego repozytorium GitHub. W tym przykładzie pokazano, jak przeprowadzić migrację modułu do korzystania z usługi ALC, zachowując jednocześnie działanie tego modułu w programie .NET Framework. Pokazano również, jak używać platformy .NET Standard i programu PowerShell Standard w celu uproszczenia podstawowej implementacji.

To rozwiązanie jest również używane przez moduł Bicep PowerShell, a wpis w blogu Rozwiązywanie konfliktów modułów programu PowerShell to kolejna dobra lektura tego rozwiązania.

Procedura obsługi rozpoznawania zestawów na potrzeby ładowania równoległego

Mimo że jest niezawodne, opisane powyżej rozwiązanie wymaga, aby zestaw modułu nie odwołył się bezpośrednio do zestawów zależności, ale zamiast tego odwołał się do zestawu otoki odwołującego się do zestawów zależności. Zestaw otoki działa jak most, przekazując wywołania z zestawu modułu do zestawów zależności. Dzięki temu zwykle nie jest to prosta ilość pracy w celu wdrożenia tego rozwiązania:

  • W przypadku nowego modułu spowoduje to dodanie dodatkowej złożoności do projektowania i implementacji
  • W przypadku istniejącego modułu wymagałoby to znacznej refaktoryzacji

Istnieje uproszczone rozwiązanie, które umożliwia równoległe ładowanie zestawów przez podłączanie Resolving zdarzenia za pomocą wystąpienia niestandardowego AssemblyLoadContext . Użycie tej metody jest łatwiejsze dla autora modułu, ale ma dwa ograniczenia. Zapoznaj się z repozytorium PowerShell-ALC-Samples , aby zapoznać się z przykładowym kodem i dokumentacją, w których opisano te ograniczenia i szczegółowe scenariusze dla tego rozwiązania.

Ważne

Nie należy używać Assembly.LoadFile w celu izolacji zależności. Użycie polecenia Assembly.LoadFile tworzy problem z tożsamością typu, gdy inny moduł ładuje inną wersję tego samego zestawu do domyślnej wersji AssemblyLoadContext. Chociaż ten interfejs API ładuje zestaw do oddzielnego AssemblyLoadContext wystąpienia, załadowane zestawy są wykrywalne za pomocą kodu rozpoznawania typów programu PowerShell. W związku z tym mogą istnieć zduplikowane typy o tej samej pełnej nazwie typu qualifed dostępnej z dwóch różnych ALC.

Niestandardowe domeny aplikacji

Ostateczną i najbardziej ekstremalną opcją izolacji zestawu jest użycie niestandardowych domen aplikacji. Domeny aplikacji są dostępne tylko w programie .NET Framework. Są one używane do zapewnienia izolacji w procesie między częściami aplikacji platformy .NET. Jednym z zastosowań jest odizolowanie obciążeń zestawów od siebie w ramach tego samego procesu.

Jednak domenyaplikacji są granicami serializacji. Nie można odwoływać się do obiektów w jednej domenie aplikacji i używać ich bezpośrednio w innej domenie aplikacji. Można to obejść, implementując element MarshalByRefObject. Ale jeśli nie kontrolujesz typów, tak jak często w przypadku zależności, nie można wymusić implementacji w tym miejscu. Jedynym rozwiązaniem jest wprowadzenie dużych zmian architektury. Granica serializacji ma również poważne konsekwencje dla wydajności.

Ponieważ domeny aplikacji mają to poważne ograniczenie, są skomplikowane do zaimplementowania i działają tylko w programie .NET Framework, nie przedstawimy przykładu sposobu ich używania w tym miejscu. Chociaż warto wspomnieć o możliwości, nie są zalecane.

Jeśli interesuje Cię próba użycia domeny aplikacji niestandardowej, poniższe linki mogą pomóc:

Rozwiązania konfliktów zależności, które nie działają w programie PowerShell

Na koniec zajmiemy się pewnymi możliwościami, które pojawiają się podczas badania konfliktów zależności platformy .NET na platformie .NET, które mogą wyglądać obiecująco, ale ogólnie nie będą działać w programie PowerShell.

Te rozwiązania mają wspólny motyw, który są zmianami konfiguracji wdrażania dla środowiska, w którym kontrolujesz aplikację i prawdopodobnie całą maszynę. Te rozwiązania są ukierunkowane na scenariusze, takie jak serwery internetowe i inne aplikacje wdrożone w środowiskach serwera, w których środowisko ma obsługiwać aplikację i jest bezpłatne do skonfigurowania przez użytkownika wdrażającego. Zwykle są one również bardzo zorientowane na platformę .NET Framework, co oznacza, że nie działają z programem PowerShell 6 lub nowszym.

Jeśli wiesz, że moduł jest używany tylko w środowiskach programu Windows PowerShell 5.1, nad którymi masz całkowitą kontrolę, niektóre z nich mogą być opcjami. Ogólnie jednak moduły nie powinny modyfikować stanu maszyny globalnej w ten sposób. Może przerwać konfiguracje, które powodują problemy z powershell.exemodułami , innymi modułami lub innymi aplikacjami zależnymi, które powodują niepowodzenie modułu w nieoczekiwany sposób.

Przekierowanie powiązania statycznego za pomocą pliku app.config w celu wymuszenia użycia tej samej wersji zależności

Aplikacje .NET Framework mogą korzystać z app.config pliku w celu deklaratywnego konfigurowania niektórych zachowań aplikacji. Można napisać app.config wpis, który konfiguruje powiązanie zestawu w celu przekierowania ładowania zestawu do określonej wersji.

Dwa problemy z tym związane z programem PowerShell to:

  • Platforma .NET Core nie obsługuje app.configprogramu , dlatego to rozwiązanie dotyczy tylko programu powershell.exe.
  • powershell.exe to udostępniona aplikacja, która znajduje się w System32 katalogu. Prawdopodobnie moduł nie będzie mógł modyfikować jego zawartości w wielu systemach. Nawet jeśli to możliwe, zmodyfikowanie elementu może spowodować przerwanie app.config istniejącej konfiguracji lub wpłynąć na ładowanie innych modułów.

Ustawienie codebase za pomocą pliku app.config

Z tych samych powodów próba skonfigurowania codebase ustawienia w app.config programie nie będzie działać w modułach programu PowerShell.

Instalowanie zależności do globalnej pamięci podręcznej zestawów (GAC)

Innym sposobem rozwiązywania konfliktów wersji zależności w programie .NET Framework jest zainstalowanie zależności do GAC, dzięki czemu różne wersje mogą być ładowane równolegle z GAC.

Ponownie w przypadku modułów programu PowerShell główne problemy są następujące:

  • Funkcja GAC ma zastosowanie tylko do programu .NET Framework, więc nie pomaga to w programie PowerShell 6 lub nowszym.
  • Instalowanie zestawów w GAC jest modyfikacją globalnego stanu maszyny i może spowodować skutki uboczne w innych aplikacjach lub innych modułach. Może to być również trudne do wykonania prawidłowo, nawet jeśli moduł ma wymagane uprawnienia dostępu. Nieprawidłowe rozwiązanie może spowodować poważne problemy dotyczące całej maszyny w innych aplikacjach platformy .NET.

Dalsze informacje

Istnieje o wiele więcej informacji na temat konfliktów zależności wersji zestawu platformy .NET. Oto kilka miłych skoków punktów: