PowerShell モジュール アセンブリの依存関係の競合の解決
C# でバイナリ PowerShell モジュールを記述する場合、機能を提供するために他のパッケージやライブラリに依存するのは自然なことです。 コードの再利用のためには、他のライブラリに依存するのは望ましいことです。 PowerShell では、アセンブリは常に同じコンテキストに読み込まれます。 そのため、モジュールの依存関係が既に読み込まれている DLL と競合し、そうでなければ同じ PowerShell セッション内で関係のないはずの 2 つのモジュールを、使用できなくなる場合があるという問題が発生します。
この問題が発生した場合は、次のようなエラー メッセージが表示されます。
この記事では、どのような場合に PowerShell で依存関係の競合が発生するか、そして依存関係の競合の問題を軽減する方法について説明します。 モジュールの作成者でない場合でも、ここで説明するいくつかのテクニックは、モジュールの使用時に発生する依存関係の競合に役立ちます。
依存関係の競合が発生する理由
.NET では、同じアセンブリの 2 つのバージョンが同じ "アセンブリ読み込みコンテキスト" に読み込まれると、依存関係の競合が発生します。 この用語の意味は、.NET プラットフォームによって若干異なります。それについては、この記事で後で説明します。 この競合は一般的な問題であり、バージョン管理された依存関係を使用するすべてのソフトウェアで発生します。
競合の問題は、プロジェクトで同じ依存関係の 2 つのバージョンに、意図的に、または直接依存することはほとんどないという事実によって、さらに複雑になります。 そうではなく、プロジェクトの複数の依存関係のそれぞれで、同じ依存関係の異なるバージョンが必要になります。
たとえば、次のような .NET アプリケーション DuckBuilder
があり、その機能の一部を実行するために 2 つの依存関係が読み込まれているとします。
Contoso.ZipTools
と Fabrikam.FileHelpers
はどちらも異なるバージョンの Newtonsoft.Json に依存しているため、各依存関係の読み込み方法によっては、依存関係の競合が発生する可能性があります。
PowerShell の依存関係での競合
PowerShell では、PowerShell 独自の依存関係が同じ共有コンテキストに読み込まれるため、依存関係の競合の問題がいっそう大きくなります。 これは、PowerShell エンジンと、読み込まれるすべての PowerShell モジュールに、競合する依存関係があってはならないことを意味します。 この典型的な例が Newtonsoft.Json です。
この例では、モジュール FictionalTools
は Newtonsoft.Json のバージョン 12.0.3
に依存していますが、これは例の PowerShell に付属する 11.0.2
より新しいバージョンの Newtonsoft.Json です。
注意
次に例を示します。 PowerShell 7.0 は現在、Newtonsoft.Json 12.0.3 に付属しています。 新しいバージョンの PowerShell には、Newtonsoft.Json の新しいバージョンがあります。
モジュールは新しいバージョンのアセンブリに依存しているため、PowerShell によって既に読み込まれているバージョンを受け付けません。 しかし、PowerShell によってアセンブリのあるバージョンが既に読み込まれているため、モジュールでは従来の読み込みメカニズムを使用して独自のバージョンを読み込むことはできません。
別のモジュールの依存関係との競合
PowerShell でのもう 1 つの一般的なシナリオは、あるバージョンのアセンブリに依存するモジュールが読み込まれた後、そのアセンブリの異なるバージョンに依存する別のモジュールが読み込まれる場合です。
多くの場合、これは次のようになります。
この場合、FictionalTools
モジュールでは FilesystemManager
モジュールより新しいバージョンの Microsoft.Extensions.Logging
が必要です。
これらのモジュールで、ルート モジュール アセンブリと同じディレクトリに依存関係アセンブリを配置することにより、依存関係を読み込むものとします。 これにより、.NET では名前を使用して暗黙的にそれらを読み込むことができます。 (.NET Core 3.1 上の) PowerShell 7.0 を実行している場合は、FictionalTools
を読み込んで実行した後、問題なく FilesystemManager
を読み込んで実行できます。 しかし、新しいセッションで FilesystemManager
を読み込んで実行した後、FictionalTools
を読み込むと、読み込まれているものより新しいバージョンの Microsoft.Extensions.Logging
が必要になるため、FictionalTools
コマンドで FileLoadException
が発生します。 同じ名前のアセンブリが既に読み込まれているため、FictionalTools
では必要なバージョンを読み込むことができません。
PowerShell と .NET
PowerShell は .NET プラットフォーム上で実行されます。これは、アセンブリの依存関係の解決と読み込みを担当します。 依存関係の競合を理解するには、ここで .NET がどのように動作するかを理解する必要があります。
また、異なるバージョンの PowerShell は異なる .NET の実装で実行されるという事実についても考える必要があります。 一般に、PowerShell 5.1 以前は .NET Framework で実行され、PowerShell 6 以降は .NET Core で実行されます。 .NET のこれら 2 つの実装では、アセンブリの読み込みと処理が異なります。 つまり、依存関係の競合の解決は、基になる .NET プラットフォームによって異なる場合があります。
アセンブリ読み込みコンテキスト
.NET では、"アセンブリ読み込みコンテキスト" (ALC) はアセンブリが読み込まれるランタイム名前空間です。 アセンブリの名前は一意である必要があります。 この概念により、各 ALC 内の名前によってアセンブリを一意に解決できます。
.NET でのアセンブリ参照読み込み
アセンブリ読み込みのセマンティクスは、.NET の実装 (.NET Core または .NET Framework) と、特定のアセンブリの読み込みに使用される .NET API の両方に依存します。 ここでは詳細については説明しません。.NET の各実装で .NET アセンブリの読み込みがどのように機能するかについて詳しくは、「関連項目」セクションのリンクをご覧ください。
この記事では、次のメカニズムについて説明します。
- .NET により .NET コード内の静的アセンブリ参照からの名前でのアセンブリの読み込みが暗黙的に試みられるときの、暗黙でのアセンブリの読み込み (実質的には
Assembly.Load(AssemblyName)
)。 Assembly.LoadFrom()
。読み込まれた DLL の依存関係を解決するためのハンドラーを追加する、プラグイン指向の読み込み API。 このメソッドでは、必要な方法で依存関係が解決されない場合があります。Assembly.LoadFile()
。要求されたアセンブリの読み込みだけが行われ、依存関係の処理は行われない、基本的な読み込み API。
.NET Framework と .NET Core の相違点
.NET Core と .NET Framework では、これらの API の動作が微妙に変更されているため、リンクの参照先をよく読んでください。 重要なのは、アセンブリ読み込みコンテキストと他のアセンブリ解決メカニズムが、.NET Framework と .NET Core の間で変更されていることです。
具体的には、.NET Framework には次の機能があります。
- グローバル アセンブリ キャッシュは、コンピューター全体のアセンブリ解決用です
- アプリケーション ドメインは、アセンブリの分離に関してはプロセス内サンドボックスと同様に機能しますが、競合するシリアル化レイヤーも提供されます
- それぞれが独自の動作を備えたアセンブリ読み込みコンテキストの固定セットがある、制限付きアセンブリ読み込みコンテキスト モデル:
- 既定の読み込みコンテキストでは、アセンブリが既定で読み込まれます
- 読み込み元コンテキストは、実行時に手動でアセンブリを読み込むためのものです
- リフレクションのみのコンテキストは、アセンブリを実行せずにそのメタデータを読み取る、安全にアセンブリを読み込むためのものです
- ミステリアス void には、
Assembly.LoadFile(string path)
とAssembly.Load(byte[] asmBytes)
で読み込まれるアセンブリが存在します
詳細については、「アセンブリの読み込みのベスト プラクティス」を参照してください。
.NET Core (および .NET 5 以降) では、この複雑さが単純なモデルに置き換えられています。
- グローバル アセンブリ キャッシュはありません。 アプリケーション自体のすべての依存関係がアプリケーションに読み込まれます。 これにより、アプリケーションで依存関係を解決するための外部要因がなくなり、依存関係の解決がより再現しやすくなります。
プラグイン ホストとしての PowerShell により、モジュールではこれが少し複雑になります。
$PSHOME
内の依存関係は、すべてのモジュールで共有されます。 - アプリケーション ドメインは 1 つだけで、新しいものを作成することはできません。 .NET では、アプリケーション ドメインの概念は .NET プロセスのグローバルな状態として維持されています。
- 新しい拡張可能なアセンブリ読み込みコンテキスト (ALC) モデル。 アセンブリの解決は、新しい ALC に配置することによって、名前空間化できます。 .NET プロセスは、すべてのアセンブリが読み込まれる 1 つの既定の ALC で始まります (
Assembly.LoadFile(string)
とAssembly.Load(byte[])
で読み込まれたものを除く)。 ただし、プロセスでは、独自の読み込みロジックを使用して独自のカスタム ALC を作成および定義することができます。 アセンブリが読み込まれるとき、最初の読み込み先 ALC によって依存関係の解決が行われます。 これにより、強力な .NET プラグイン読み込みメカニズムを実装することができます。
どちらの実装でも、アセンブリは遅延読み込みされます。 これは、その型を必要とするメソッドが初めて実行されるときに読み込まれることを意味します。
たとえば、同じコードの 2 つのバージョンがあり、依存関係を読み込むタイミングが異なるとします。
1 番目では、依存関係の参照がメソッド内に構文的に存在するため、依存関係は 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;
}
}
2 番目では、メソッドによる内部間接参照であるため、依存関係は 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 が最小限になり、リソースの使用効率が向上するため、これは良い方法です。 残念ながら、これには、アセンブリの読み込みを試みるコード パスに達するまで、アセンブリの読み込みが失敗することがわからないという副作用があります。
また、アセンブリ読み込みの競合に対して、タイミング条件が作成される可能性もあります。 同じプログラムの 2 つの部分で、同じアセンブリの異なるバージョンの読み込みが試みられた場合、読み込まれるバージョンは、最初に実行されるコード パスによって決まります。
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 に存在しないため、代わりにモジュール フォルダーから読み込まれます。
一般に、Microsoft.PowerShell.Sdk や System.Management.Automation などの具体的な PowerShell パッケージをターゲットとする場合、NuGet で必要とされる適切な依存関係のバージョンを解決できるはずです。 Windows PowerShell と PowerShell 6 以降の両方をターゲットにすると、複数のフレームワークまたは PowerShellStandard.Library のどちらを対象にするかを選択する必要があるため、より困難になります。
次のような状況では、共通の依存関係バージョンへの固定は動作しません。
- 競合が間接的な依存関係に関するものであり、どの依存関係も共通バージョンを使用するように構成できない。
- 他の依存関係のバージョンが頻繁に変更される可能性があるため、共通バージョンへの固定は短期的な解決にすぎない。
プロセス外の依存関係を使用する
この解決策は、モジュール作成者よりモジュール ユーザー向けです。 この解決策は、既存の依存関係の競合が原因でモジュールが動作しない場合に使用します。
依存関係の競合は、同じアセンブリの 2 つのバージョンが同じ .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 でのもう 1 つのオプションは、
Import-Module
で-UseWindowsPowerShell
フラグを使用することです。 これにより、ローカル リモート処理セッションを通じて Windows PowerShell にモジュールがインポートされます。Import-Module -Name ConflictingModule -UseWindowsPowerShell
Windows PowerShell では、モジュールに互換性がない場合、または動作が異なる場合があることに注意してください。
プロセス外の呼び出しを使用してはいけない場合
モジュールの作成者に関しては、プロセス外のコマンド呼び出しをモジュールに組み込むのは難しく、問題の原因となるエッジ ケースが存在する場合があります。 具体的には、モジュールが動作する必要がある環境によっては、リモート処理とジョブを利用できない場合があります。 ただし、実装をプロセス外に移動し、PowerShell モジュールをシン クライアントとして使用できるようにするための一般的な原則が、引き続き適用される可能性があります。
モジュールのユーザーに関しては、プロセス外の呼び出しが機能しない場合があります。
- 機能を使用する権限がないか、機能が有効になっていないため、PowerShell リモート処理を使用できない場合。
- メソッドまたは別のコマンドへの入力として、特定の .NET 型が出力で必要な場合。 PowerShell リモート処理経由で実行されるコマンドからは、厳密に型指定された .NET オブジェクトではなく、逆シリアル化されたオブジェクトが出力されます。 これは、リモート処理経由でインポートされたコマンドの出力では、メソッドの呼び出しや厳密に型指定された API が動作しないことを意味します。
より堅牢な解決策
これまでの解決策にはすべて、動作しないシナリオとモジュールがありました。 ただし、これらには、正しく実装するのが比較的簡単であるという長所もあります。 以下のソリューションはより堅牢ですが、適切に実装するにはより多くの労力が必要になり、慎重に記述されていない場合は微妙なバグが発生する可能性があります。
.NET Core アセンブリ読み込みコンテキスト経由での読み込み
.NET Core 1.0 では、同じアセンブリの複数のバージョンを同じランタイムに読み込む必要がある場合に特に対処するため、アセンブリ読み込みコンテキスト (ALC) が導入されました。
.NET では、それらによって、アセンブリの競合するバージョンの読み込みの問題に対する最も堅牢な解決策が提供されます。 ただし、カスタム ALC は .NET Framework では使用できません。 これは、この解決策が PowerShell 6 以降でのみ機能することを意味します。
現在、PowerShell での依存関係の分離に対して ALC を使用する場合の最適な例は、Visual Studio Code 用の PowerShell 拡張機能に対する言語サーバーである PowerShell エディター サービスでのものです。 PowerShell モジュール内で PowerShell エディター サービスの独自の依存関係がクラッシュしないように、ALC が使用されます。
ALC を使用してモジュールの依存関係の分離を実装することは、概念的には難しいことですが、ここでは最小限の例について説明します。 PowerShell 7 だけで動作することが意図された簡単なモジュールについて考えます。 ソース コードは次のように構成されています。
+ AlcModule.psd1
+ src/
+ TestAlcModuleCommand.cs
+ AlcModule.csproj
コマンドレットの実装は次のようになります。
using Shared.Dependency;
namespace AlcModule
{
[Cmdlet(VerbsDiagnostic.Test, "AlcModule")]
public class TestAlcModuleCommand : Cmdlet
{
protected override void EndProcessing()
{
// Here's where our dependency gets used
Dependency.Use();
// Something trivial to make our cmdlet do *something*
WriteObject("done!");
}
}
}
(大幅に単純化された) マニフェストは次のようになります。
@{
Author = 'Me'
ModuleVersion = '0.0.1'
RootModule = 'AlcModule.dll'
CmdletsToExport = @('Test-AlcModule')
PowerShellVersion = '7.0'
}
そして、csproj
は次のようになります。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Shared.Dependency" Version="1.0.0" />
<PackageReference Include="Microsoft.PowerShell.Sdk" Version="7.0.1" PrivateAssets="all" />
</ItemGroup>
</Project>
このモジュールをビルドすると、生成される出力のレイアウトは次のようになります。
AlcModule/
+ AlcModule.psd1
+ AlcModule.dll
+ Shared.Dependency.dll
この例の Shared.Dependency.dll
アセンブリには、競合する依存関係の問題があるものと仮定します。 これは、モジュール固有のバージョンを使用できるように、ALC の背後に配置する必要がある依存関係です。
次のようにモジュールを再設計する必要があります。
- 競合が発生しないように、モジュールの依存関係は、PowerShell の ALC ではなく、カスタム ALC にのみ読み込みます。 さらに、プロジェクトに依存関係を追加するとき、読み込みが動作し続けるよう、コードを継続的に追加するようなことはしたくありません。 代わりに、再利用可能で汎用的な依存関係解決ロジックが必要です。
- PowerShell でのモジュールの読み込みは通常どおりに動作します。 PowerShell モジュール システムで必要なコマンドレットと他の型は、PowerShell 独自の ALC 内で定義されています。
これらの 2 つの要件を仲介するには、モジュールを 2 つのアセンブリに分割する必要があります。
- PowerShell のモジュール システムでモジュールを正しく読み込むために必要なすべての型の定義が含まれるコマンドレット アセンブリ
AlcModule.Cmdlets.dll
。 つまり、Cmdlet
基底クラスと、AssemblyLoadContext.Default.Resolving
でカスタム ALC を通してAlcModule.Engine.dll
が適切に読み込まれるようにイベント ハンドラーを設定するIModuleAssemblyInitializer
が実装されているクラスの、すべての実装。 PowerShell 7 では、他の ALC で読み込まれるアセンブリで定義されている型は意図的に非表示にされているため、PowerShell にパブリックに公開される型は、ここでも定義する必要があります。 最後に、カスタム ALC の定義がこのアセンブリで定義されている必要があります。 このアセンブリ内のそれ以外のコードは、できるだけ少なくする必要があります。 - モジュールの実際の実装を処理するエンジン アセンブリ
AlcModule.Engine.dll
。 PowerShell ALC でこれに含まれる型を使用できますが、最初はカスタム ALC を通じて読み込まれます。 その依存関係は、カスタム ALC にのみ読み込まれます。 実質的に、これは 2 つの ALC 間の "ブリッジ" になります。
このブリッジの概念を使用すると、新しいアセンブリの状況は次のようになります。
既定の ALC の依存関係プローブ ロジックによって、カスタム ALC に読み込まれる依存関係が解決されないようにするには、モジュールのこれら 2 つの部分を、異なるディレクトリに分ける必要があります。 新しいモジュール レイアウトは次のような構造になります。
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 用にラップされる機能に対する .NET API と考える必要があります。
AlcModule.Cmdlets.dll
のコマンドレットは次のようになります。
// Reference our module's Engine implementation here
using AlcModule.Engine;
namespace AlcModule.Cmdlets
{
[Cmdlet(VerbsDiagnostic.Test, "AlcModule")]
public class TestAlcModuleCommand : Cmdlet
{
protected override void EndProcessing()
{
AlcEngine.Use();
WriteObject("done!");
}
}
}
この時点で、AlcModule を読み込んで Test-AlcModule
を実行すると、既定の ALC によって Alc.Engine.dll
が読み込まれて EndProcessing()
の実行が試みられるときに、FileNotFoundException が発生します。 これは、非表示にする依存関係が既定の ALC によって検出されないことを意味するので、適切なことです。
次に、AlcModule.Engine.dll
の解決方法を認識できるように、AlcModule.Cmdlets.dll
にコードを追加する必要があります。 最初に、モジュールの Dependencies
ディレクトリからアセンブリを解決するカスタム ALC を定義する必要があります。
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 バージョンです。 EndProcessing()
が呼び出されると、AlcModule.Engine.dll
を見つけるためにこのイベントが発生します。
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);
}
}
}
新しい実装で、モジュールが読み込まれて Test-AlcModule
が実行されるときに発生する呼び出しのシーケンスを確認します。
興味深い点は次のとおりです。
- モジュールで
Resolving
イベントが読み込まれて設定されると、IModuleAssemblyInitializer
が最初に実行されます。 Test-AlcModule
が実行され、そのEndProcessing()
メソッドが呼び出されるまで、依存関係は読み込まれません。EndProcessing()
が呼び出されると、既定の ALC ではAlcModule.Engine.dll
を検出できず、Resolving
イベントが発生します。- イベント ハンドラーによってカスタム ALC が既定の ALC にフックされ、
AlcModule.Engine.dll
のみが読み込まれます。 AlcModule.Engine.dll
内でAlcEngine.Use()
が呼び出されると、カスタム ALC が再び開始してShared.Dependency.dll
が解決されます。 具体的には、既定の ALC のいずれとも競合せず、Dependencies
ディレクトリだけが検索されるので、"この"Shared.Dependency.dll
が常に読み込まれます。
実装をアセンブルすると、新しいソース コード レイアウトは次のようになります。
+ 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.Engine
に存在しなかったすべてのものを、AlcModule.Cmdlets
からベース モジュール ディレクトリにコピーします
ここでのモジュール レイアウトは依存関係の分離にとって非常に重要であるため、ソース ルートから使用するビルド スクリプトは次のようになります。
param(
# The .NET build configuration
[ValidateSet('Debug', 'Release')]
[string]
$Configuration = 'Debug'
)
# Convenient reusable constants
$mod = "AlcModule"
$netcore = "netcoreapp3.1"
$copyExtensions = @('.dll', '.pdb')
# Source code locations
$src = "$PSScriptRoot/src"
$engineSrc = "$src/$mod.Engine"
$cmdletsSrc = "$src/$mod.Cmdlets"
# Generated output locations
$outDir = "$PSScriptRoot/out/$mod"
$outDeps = "$outDir/Dependencies"
# Build AlcModule.Engine
Push-Location $engineSrc
dotnet publish -c $Configuration
Pop-Location
# Build AlcModule.Cmdlets
Push-Location $cmdletsSrc
dotnet publish -c $Configuration
Pop-Location
# Ensure out directory exists and is clean
Remove-Item -Path $outDir -Recurse -ErrorAction Ignore
New-Item -Path $outDir -ItemType Directory
New-Item -Path $outDeps -ItemType Directory
# Copy manifest
Copy-Item -Path "$PSScriptRoot/$mod.psd1"
# Copy each Engine asset and remember it
$deps = [System.Collections.Generic.Hashtable[string]]::new()
Get-ChildItem -Path "$engineSrc/bin/$Configuration/$netcore/publish/" |
Where-Object { $_.Extension -in $copyExtensions } |
ForEach-Object { [void]$deps.Add($_.Name); Copy-Item -Path $_.FullName -Destination $outDeps }
# Now copy each Cmdlets asset, not taking any found in Engine
Get-ChildItem -Path "$cmdletsSrc/bin/$Configuration/$netcore/publish/" |
Where-Object { -not $deps.Contains($_.Name) -and $_.Extension -in $copyExtensions } |
ForEach-Object { Copy-Item -Path $_.FullName -Destination $outDir }
最後に、時間の経過と共に依存関係が追加されても堅牢な状態が維持されるアセンブリ読み込みコンテキストでモジュールの依存関係を分離する、一般的な方法があります。
詳細な例については、こちらの GitHub リポジトリを参照してください。 この例では、.NET Framework 内でモジュールを動作させたまま、ALC を使用するようにそのモジュールを移行する方法が示されています。 また、.NET Standard と PowerShell Standard を使用して、中核となる実装を簡略化する方法についても説明されています。
この解決策は Bicep PowerShell モジュールでも使用されています。この解決策については、ブログ記事「Resolving PowerShell Module Conflicts (PowerShell モジュールの競合の解決)」も参考になります。
side-by-side 読み込みのためのアセンブリ解決ハンドラー
上記の解決策は堅牢ですが、モジュール アセンブリが依存関係アセンブリを直接参照するのではなく、ラッパー アセンブリを参照して、それが依存関係アセンブリを参照するようにする必要があります。 ラッパー アセンブリはブリッジのように機能し、モジュール アセンブリからの呼び出しを依存関係アセンブリに転送します。 そのため、通常、この解決策を採用するための作業量は少なくありません。
- 新しいモジュールの場合、これにより設計と実装がさらに複雑になります
- 既存のモジュールの場合、大幅なリファクタリングが必要になります
カスタム AssemblyLoadContext
インスタンスを使用して Resolving
イベントをフックすることで、side-by-side アセンブリ読み込みを実現する、簡素化されたソリューションがあります。 モジュール作成者にとってはこのメソッドを使う方が簡単ですが、2 つの制限事項があります。 このソリューションのサンプル コードと、それらの制限事項と詳細なシナリオを説明したドキュメントについては、PowerShell-ALC-Samples リポジトリを参照してください。
重要
依存関係の分離のためには Assembly.LoadFile
を使用しないでください。 Assembly.LoadFile
を使用すると、別のモジュールが同じアセンブリの別のバージョンを既定の AssemblyLoadContext
に読み込んだ時に、"型 ID" の問題が発生します。 この API はアセンブリを別の AssemblyLoadContext
インスタンスに読み込みますが、読み込まれたアセンブリは PowerShell の型解決コードによって検出できます。 そのため、同じ完全修飾型名を持つ重複する型が、2 つの異なる ALC から使用できるようになるおそれがあります。
カスタム アプリケーション ドメイン
アセンブリを分離するための最後の最も極端なオプションは、カスタム アプリケーション ドメインを使用することです。 アプリケーション ドメインは、.NET Framework でのみ使用できます。 これらは、.NET アプリケーションのパーツをプロセス内で分離するために使用されます。 用途の 1 つは、同じプロセス内でアセンブリの読み込みを相互に分離することです。
ただし、アプリケーション ドメインはシリアル化の境界です。 あるアプリケーション ドメイン内のオブジェクトを、別のアプリケーション ドメイン内のオブジェクトから直接参照および使用することはできません。 この問題を回避するには、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 では、次の 2 つの問題があります。
- .NET Core では
app.config
がサポートされていないため、この解決策はpowershell.exe
にのみ適用されます。 powershell.exe
は、System32
ディレクトリに存在する共有アプリケーションです。 多くのシステムでは、モジュールでその内容を変更できない可能性があります。 可能な場合でも、app.config
を変更すると、既存の構成が壊れたり、他のモジュールの読み込みに影響したりする可能性があります。
app.config での codebase
の設定
同じ理由から、app.config
で codebase
の設定を構成しようとしても、PowerShell モジュールでは機能しません。
グローバル アセンブリ キャッシュ (GAC) への依存関係のインストール
.NET Framework での依存関係のバージョンの競合を解決するもう 1 つの方法は、GAC に依存関係をインストールし、GAC から異なるバージョンを side-by-side で読み込めるようにすることです。
やはり、PowerShell モジュールの場合、ここでの主な問題は次のとおりです。
- GAC は .NET Framework にのみ適用されるため、PowerShell 6 以降では役に立ちません。
- GAC へのアセンブリのインストールは、コンピューターのグローバルな状態の変更であり、他のアプリケーションや他のモジュールで副作用が発生する可能性があります。 また、モジュールに必要なアクセス特権がある場合でも、正しく行うことは難しい可能性があります。 誤って行うと、他の .NET アプリケーションでコンピューター全体の深刻な問題が発生する可能性があります。
関連項目
.NET アセンブリのバージョンの依存関係の競合については、多くの資料があります。 手始めに読むのに適したものをいくつか紹介します。
- .NET: .NET のアセンブリ
- .NET Core: マネージド アセンブリの読み込みアルゴリズム
- .NET Core: System.Runtime.Loader.AssemblyLoadContext について
- .NET Core: side-by-side アセンブリ読み込みソリューションについての説明
- .NET Framework: アセンブリ バージョンのリダイレクト
- .NET Framework: アセンブリの読み込みのベスト プラクティス
- .NET Framework: ランタイムがアセンブリを検索する方法
- .NET Framework: アセンブリ読み込みを解決する
- StackOverflow:アセンブリ バインドのリダイレクト、方法、理由
- PowerShell: AssemblyLoadContexts の実装に関する説明
- PowerShell:
Assembly.LoadFile()
で既定の AssemblyLoadContext に読み込まれない - Rick Strahl: .NET アセンブリの依存関係はいつ読み込まれるか
- Jon Skeet: .NET でのバージョン管理の概要
- Nate McMaster: .NET Core プリミティブの詳細
PowerShell
フィードバック
https://aka.ms/ContentUserFeedback」を参照してください。
以下は間もなく提供いたします。2024 年を通じて、コンテンツのフィードバック メカニズムとして GitHub の issue を段階的に廃止し、新しいフィードバック システムに置き換えます。 詳細については、「フィードバックの送信と表示