解決 PowerShell 模組元件相依性衝突

在 C# 中撰寫二進位 PowerShell 模組時,自然會採用其他套件或連結庫的相依性來提供功能。 重複使用程式代碼時,最好對其他連結庫採取相依性。 PowerShell 一律會將元件載入相同的內容。 當模組的相依性與已載入的 DLL 衝突時,可能會造成問題,而且可能會在相同的 PowerShell 會話中使用兩個其他無關的模組。

如果您遇到此問題,您會看到如下的錯誤訊息:

元件載入衝突錯誤訊息

本文探討 PowerShell 中發生相依性衝突的一些方式,以及減輕相依性衝突問題的方式。 即使您不是模組作者,這裡也有一些技巧可協助您解決您在使用的模塊中發生的相依性衝突。

為什麼會發生相依性衝突?

在 .NET 中,當相同元件的兩個版本載入相同的 元件載入內容時,會發生相依性衝突。 此字詞表示不同 .NET 平臺上稍有不同之處,本文稍後會 涵蓋 。 此衝突是使用版本相依性的任何軟體中常見的問題。

衝突問題因專案幾乎永遠不會刻意或直接相依於兩個相同相依性版本而加劇。 相反地,專案有兩個或多個相依性,每個相依性都需要不同版本的相同相依性。

例如,假設您的 .NET 應用程式 DuckBuilder引進兩個相依性,以執行其功能的某些部分,如下所示:

DuckBuilder 的兩個相依性依賴不同版本的 Newtonsoft.Json

由於 Contoso.ZipToolsFabrikam.FileHelpers 都相依於不同版本的 Newtonsoft.Json,因此視每個相依性載入方式而定,可能會發生相依性衝突。

與 PowerShell 相依性衝突

在PowerShell中,相依性衝突問題會放大,因為PowerShell自己的相依性會載入相同的共享內容。 這表示PowerShell引擎和所有載入的PowerShell模組不得有衝突的相依性。 這是 Newtonsoft.Json的一個傳統範例:

FictionalTools 模組相依於較新版本的 Newtonsoft.Json,而不是 PowerShell

在此範例中,模組 FictionalTools 相依於 Newtonsoft.Json 版本 12.0.3,這是較新版本 的 Newtonsoft.Json 比範例 PowerShell 隨附的 11.0.2

注意

這是範例。 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.LoggingFictionalTools 無法載入所需的版本,因為已載入相同名稱的元件。

PowerShell 和 .NET

PowerShell 會在 .NET 平台上執行,負責解析和載入元件相依性。 我們必須瞭解 .NET 在這裡的運作方式,以瞭解相依性衝突。

我們也必須面對不同版本的PowerShell在不同的 .NET 實作上執行的事實。 一般而言,PowerShell 5.1 和以下版本會在 .NET Framework 上執行,而在 .NET Core 上執行 PowerShell 6 和更新版本。 這兩個 .NET 載實作和處理元件的方式不同。 這表示解決相依性衝突可能會因基礎 .NET 平臺而異。

元件載入內容

在 .NET 中,元件載入內容 (ALC) 是載入元件的運行時間命名空間。 元件的名稱必須是唯一的。 此概念可讓每個 ALC 中依名稱唯一解析元件。

.NET 中的元件參考載入

元件載入的語意取決於 .NET 實作 (.NET Core 與 .NET Framework) 和用來載入特定元件的 .NET API。 進一步閱讀 一節中有連結,進一步詳細說明 .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[])的元件除外)。 但此程式可以使用自己的載入邏輯來建立及定義自己的自定義 ALC。 載入元件時,其載入的第一個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();
    }
}

這是很好的作法,因為它可將記憶體和文件系統 I/O 降到最低,並且更有效率地使用資源。 不幸的副作用是,在到達嘗試載入元件的程式代碼路徑之前,我們不知道元件無法載入。

它也可以建立元件載入衝突的計時條件。 如果相同程式的兩個部分嘗試載入相同元件的不同版本,則載入的版本取決於先執行哪一個程式代碼路徑。

針對 PowerShell,這表示下列因素可能會影響元件負載衝突:

  • 第一次載入哪一個模組?
  • 使用相依性連結庫的程式代碼路徑是否已執行?
  • PowerShell 會在啟動時載入衝突的相依性,還是只在特定程式代碼路徑下載入?

快速修正及其限制

在某些情況下,您可以對模組進行小調整,並以最少的努力來修正問題。 但這些解決方案往往附帶警告。 雖然它們可能會套用至您的課程模組,但不適用於每個模組。

變更相依性版本

避免相依性衝突最簡單的方式是就相依性達成一致。 這可能會在下列情況下:

  • 您的衝突與模組的直接相依性,而您可以控制版本。
  • 您的衝突是間接相依性,但您可以將直接相依性設定為使用可運作的間接相依性版本。
  • 您知道衝突的版本,而且可以依賴它不變更。

Newtonsoft.Json 套件是最後一個案例的良好範例。 這是 PowerShell 6 和更新版本的相依性,而且不會在 Windows PowerShell 中使用。 這意味著解決版本控制衝突的簡單方式,就是以您想要目標的PowerShell版本 Newtonsoft.Json的最低版本 為目標。

例如,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.SdkSystem.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 中引進了元件載入內容 (ALC),專門解決將相同元件的多個版本載入至相同運行時間的需求。

在 .NET 中,它們提供最健全的解決方案,解決載入元件衝突版本的問題。 不過,.NET Framework 中沒有自訂 ALC 的支援。 這表示此解決方案僅適用於PowerShell6和更新版本。

目前,在 PowerShell 中使用 ALC 進行相依性隔離的最佳範例是在 PowerShell 編輯器服務中,這是適用於 Visual Studio Code 的 PowerShell 擴充功能語言伺服器。 ALC 可用來防止 PowerShell 編輯器服務本身的相依性與 PowerShell 模組中的相依性發生衝突。

使用 ALC 實作模組相依性隔離在概念上很困難,但我們會透過最少的範例來運作。 想像我們有一個簡單的模組,只設計用來在 PowerShell 7 中運作。 原始碼的組織方式如下:

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

Cmdlet 實作看起來像這樣:

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,而不是載入 PowerShell 的 ALC,因此不會發生任何衝突。 此外,當我們將更多相依性新增至專案時,我們不想持續新增更多程式碼來繼續載入工作。 相反地,我們想要可重複使用的泛型相依性解析邏輯。
  • 載入模組在PowerShell中仍可正常運作。 PowerShell 模組系統所需的 Cmdlet 和其他類型是在 PowerShell 自己的 ALC 內定義。

若要調解這兩個需求,我們必須將模組分成兩個元件:

  • Cmdlet 元件 AlcModule.Cmdlets.dll,其中包含 PowerShell 模組系統必須正確載入模組的所有類型定義。 也就是說,Cmdlet 基類的任何實作,以及實作 IModuleAssemblyInitializer的 類別,其會設定 AssemblyLoadContext.Default.Resolving 的事件處理程式,以透過我們的自定義 ALC 正確載入 AlcModule.Engine.dll。 由於 PowerShell 7 會刻意隱藏在其他 ALC 載入之元件中定義的類型,因此也必須在這裡定義任何要公開給 PowerShell 的類型。 最後,必須在這個元件中定義我們的自定義 ALC 定義。 除此之外,盡可能少的程式代碼應該存在於這個元件中。
  • 處理模組實際實作的引擎元件 AlcModule.Engine.dll。 PowerShell ALC 中提供此類型,但一開始會透過我們的自定義 ALC 載入。 其相依性只會載入自定義 ALC。 實際上,這已成為兩個 ALC 之間的 橋樑

使用此網橋概念,我們的新元件情況如下所示:

圖表,代表 AlcModule.Engine.dll 橋接兩個ALC

若要確定預設 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的簡單容器,但您應該將其視為其他元件包裝 PowerShell 中 Cmdlet 功能的 .NET API。

AlcModule.Cmdlets.dll 中的 Cmdlet 看起來像這樣:

// 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,當預設 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 事件,這是應用程式域上 AssemblyResolve 事件的 ALC 版本。 呼叫 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.CmdletsAlcModule.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 }

最後,我們有一種通用的方法,可以在 Assembly Load Context 中隔離我們的模組的相依關係,隨著相依關係的不斷增加,依然保持穩定性。

如需更詳細的範例,請移至此 GitHub 存放庫。 此範例示範如何移轉模組以使用 ALC,同時讓該模組在 .NET Framework 中運作。 它也會示範如何使用 .NET Standard 和 PowerShell Standard 來簡化核心實作。

Bicep PowerShell 模組也會使用此解決方案,而 解決 PowerShell 模組衝突的部落格文章 是另一個關於此解決方案的好讀文章。

並行載入的元件解析處理程式

雖然是健全的,但上述解決方案需要模組元件不直接參考相依性元件,而是參考參考相依性元件的包裝函式元件。 包裝函式元件的作用就像網橋一樣,將模組元件的呼叫轉送至相依性元件。 這通常使採用此解決方案的工作量不簡單:

  • 對於新的模組,這會為設計和實作增加額外的複雜度
  • 對於現有的模組,這需要大量的重構

透過將 Resolving 事件與自定義 AssemblyLoadContext 實例連結,有一個簡化的解決方案可達成並存元件載入。 對於模組作者來說,使用這個方法會比較容易,但有兩個限制。 請參閱 PowerShell-ALC-Samples 存放庫,以取得範例程式代碼和文件,說明此解決方案的這些限制和詳細案例。

重要

不要用 Assembly.LoadFile 來隔離依賴。 當另一個模組將不同版本的相同元件載入預設 Assembly.LoadFile時,使用 建立 AssemblyLoadContext 問題。 雖然此 API 會將元件載入個別的 AssemblyLoadContext 實例,但 PowerShell 的 類型解析程式代碼可以探索載入的元件。 因此,可能會有重複的類型與兩個不同的 ALC 提供相同的完全四分型別名稱。

自訂應用程式域

元件隔離的最終和最極端選項是使用自訂 應用程式域應用程式域 僅適用於 .NET Framework。 它們用來在 .NET 應用程式的各部分之間提供進程內隔離。 其中一個用途是隔離元件在相同進程中彼此的負載。

不過,應用程式域是串行化界限。 一個應用程式域中的物件無法直接被另一個應用程式域中的物件參考及使用。 您可以實作 MarshalByRefObject來解決此問題。 但是,當您不控制類型時,與相依性的情況一樣,就不可能在這裡強制實作。 唯一的解決方案是進行大型架構變更。 串行化界限也具有嚴重的效能影響。

由於 應用程式域 具有此嚴重限制,因此實作很複雜,而且只能在 .NET Framework 中運作,因此我們不會在此提供您如何使用它們的範例。 雖然他們值得一提,但不建議這樣做。

如果您有興趣嘗試使用自訂應用程式域,下列連結可能會有説明:

不適用於PowerShell的相依性衝突解決方案

最後,我們將解決在 .NET 中研究 .NET 相依性衝突時,可能看起來很有希望的可能性,但通常不適用於 PowerShell。

這些解決方案的共同主題是,在一個你能控制應用程式,甚至可能控制整台機器的環境中,進行部署配置的修改。 這些解決方案適用於 Web 伺服器和其他部署至伺服器環境的應用程式等案例,其中環境是用來裝載應用程式,且可供部署使用者自由設定。 它們也傾向於以 .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 可能會中斷現有的組態,或影響其他模組的載入。

使用 app.config 設定 codebase

基於同樣的原因,嘗試在 app.config 中配置 codebase 設定在 PowerShell 模組中是行不通的。

安裝全域程式集快取的相依性 (GAC)

解決 .NET Framework 中相依性版本衝突的另一種方式是將相依性安裝到 GAC,以便從 GAC 並存載入不同的版本。

同樣地,針對 PowerShell 模組,此處的主要問題如下:

  • GAC 只適用於 .NET Framework,所以這對 PowerShell 6 及以上版本沒什麼幫助。
  • 將元件安裝至 GAC 是對全域計算機狀態的修改,而且可能會對其他應用程式或其他模組造成副作用。 即使您的模組具有必要的訪問許可權,也可能很難正確執行。 弄錯可能會導致其他 .NET 應用程式中發生嚴重且全機器的問題。

進一步閱讀

如需更多關於 .NET 元件版本相依性衝突的詳細資訊,請參閱。 以下是一些不錯的跳躍點: