Note
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de vous connecter ou de modifier les répertoires.
L’accès à cette page nécessite une autorisation. Vous pouvez essayer de changer de répertoire.
Lors de l’écriture d’un module PowerShell binaire en C#, il est naturel de prendre des dépendances envers d’autres packages ou bibliothèques afin de fournir des fonctionnalités. Le recours à des dépendances envers d’autres bibliothèques est souhaitable pour la réutilisation du code. PowerShell charge toujours les assemblys dans le même contexte. Cela pose des problèmes lorsque les dépendances d’un module sont en conflit avec des DLL déjà chargées, et peut empêcher l’utilisation de deux modules autrement non associés dans la même session PowerShell.
Lorsque ce problème se produit, un message d’erreur semblable à celui-ci s’affiche :
Cet article examine pourquoi les conflits de dépendances se produisent dans PowerShell, et présente différentes manières d’atténuer les problèmes de conflit de dépendances. Même si vous n’êtes pas auteur de module, vous trouverez dans cet article quelques astuces qui pourront vous aider en cas de conflit de dépendances dans les modules que vous utilisez.
Pourquoi les conflits de dépendances se produisent-ils ?
Dans .NET, les conflits de dépendances se produisent lorsque deux versions du même assembly sont chargées dans le même contexte de chargement d’assembly. Ce terme signifie des choses légèrement différentes sur les différentes plateformes .NET ; nous y reviendrons plus loin dans cet article. Ce conflit est un problème courant qui se produit dans tout logiciel où des dépendances avec version sont utilisées.
Les problèmes de conflits sont accrus par le fait qu’un projet ne dépend presque jamais délibérément ou directement de deux versions de la même dépendance. Au lieu de cela, le projet a deux dépendances (ou plus) qui nécessitent chacune une version différente de la même dépendance.
Par exemple, imaginez que votre application .NET, DuckBuilder, intègre deux dépendances afin d’exécuter des parties de ses fonctionnalités et ressemble à ceci :
Contoso.ZipTools et Fabrikam.FileHelpers dépendant tous deux de différentes versions de Newtonsoft.Json, il peut y avoir un conflit de dépendances en fonction de la façon dont chaque dépendance est chargée.
Conflits avec les dépendances de PowerShell
Dans PowerShell, le problème de conflit de dépendances est amplifié, car les propres dépendances de PowerShell sont chargées dans le même contexte partagé. Cela signifie que le moteur PowerShell et tous les modules PowerShell chargés ne doivent pas avoir de dépendances en conflit. Newtonsoft.Json en est un exemple classique :
Dans cet exemple, le module FictionalTools dépend de la version de 12.0.3, qui est une version plus récente de Newtonsoft.Json que 11.0.2 qui est fournie dans l’exemple PowerShell.
Remarque
Voici un exemple. PowerShell 7.0 est actuellement fourni avec Newtonsoft.Json 12.0.3. Les versions plus récentes de PowerShell ont des versions plus récentes de Newtonsoft.Json.
Étant donné que le module dépend d’une version plus récente de l’assembly, il n’acceptera pas la version que PowerShell a déjà chargée. Toutefois, comme PowerShell a déjà chargé une version de l’assembly, le module ne peut pas charger sa propre version à l’aide du mécanisme de chargement conventionnel.
Conflits avec les dépendances d’un autre module
Un autre scénario courant dans PowerShell est le chargement d’un module qui dépend d’une version d’un assembly, puis le chargement d’un autre module qui dépend d’une version différente de cet assembly.
Cela ressemble souvent à ceci :
Dans ce cas, le module FictionalTools nécessite une version plus récente de Microsoft.Extensions.Logging que le module FilesystemManager.
Imaginez que ces modules chargent leurs dépendances en plaçant les assemblys de dépendances dans le même répertoire que l’assembly de module racine. Cela permet à .NET de les charger implicitement par nom. Si nous exécutons PowerShell 7.0 (sur .NET Core 3.1), nous pouvons charger et exécuter FictionalTools, puis charger et exécuter FilesystemManager sans problème. Toutefois, dans une nouvelle session, si nous chargeons et exécutons FilesystemManager, puis chargeons FictionalTools, nous recevons une FileLoadException à partir de la commande FictionalTools, car elle nécessite une version plus récente de Microsoft.Extensions.Logging que celle qui est chargée.
FictionalTools ne peut pas charger la version requise, car un assembly du même nom a déjà été chargé.
PowerShell et .NET
PowerShell s’exécute sur la plateforme .NET, qui est responsable de la résolution et du chargement des dépendances d’assembly. Nous devons comprendre comment .NET fonctionne ici pour comprendre les conflits de dépendances.
Nous devons également gérer le fait que différentes versions de PowerShell s’exécutent sur différentes implémentations de .NET. En général, PowerShell 5.1 et versions antérieures s’exécutent sur le .NET Framework, tandis que PowerShell 6 et versions ultérieures s’exécutent sur .NET Core. Ces deux implémentations de .NET chargent et gèrent les assemblys différemment. Cela signifie que la résolution des conflits de dépendances peut varier en fonction de la plateforme .NET sous-jacente.
Contextes de chargement d’assembly
Dans .NET, un contexte de chargement d’assemblys (ALC, Assembly Load Context) est un espace de noms de runtime dans lequel des assemblys sont chargés. Les noms des assemblys doivent être uniques. Ce concept permet aux assemblys d’être résolus de manière unique par nom dans chaque ALC.
Chargement des références d’assembly dans .NET
La sémantique du chargement d’assembly dépend à la fois de l’implémentation .NET (.NET Core ou .NET Framework) et de l’API .NET utilisée pour charger un assembly particulier. Plutôt que d’entrer ici dans les détails, nous vous invitons à consulter les liens fournis dans la section Lectures supplémentaires, qui expliquent en détail le fonctionnement du chargement d’assembly .NET dans chaque implémentation .NET.
Dans cet article, nous allons faire référence aux mécanismes suivants :
- Chargement d’assembly implicite (
Assembly.Load(AssemblyName)), lorsque .NET essaie de charger un assembly par nom à partir d’une référence d’assembly statique dans du code .NET -
Assembly.LoadFrom(), une API de chargement orientée plug-in qui ajoute des gestionnaires pour résoudre les dépendances de la DLL chargée. Cette méthode est susceptible de ne pas résoudre les dépendances comme nous le souhaitons -
Assembly.LoadFile(), une API de chargement de base censée charger uniquement l’assembly demandé et qui ne gère pas de dépendances.
Différences entre le .NET Framework et .NET Core
Le fonctionnement de ces API a changé de manière subtile entre .NET Core et le .NET Framework. Il est donc utile de consulter les liens proposés. Il convient surtout de remarquer que les contextes de chargement d’assembly et d’autres mécanismes de résolution d’assembly ont changé entre le .NET Framework et .NET Core.
En particulier, le .NET Framework dispose des fonctionnalités suivantes :
- Le Global Assembly Cache, pour la résolution d’assembly à l’échelle de la machine
- Des domaines d’application, qui opèrent comme des bacs à sable en mode in-process pour l’isolation d’assembly, mais présentent également une couche de sérialisation à gérer
- Un modèle de contexte de chargement d’assemblys limité, qui a un ensemble fixe de contextes de chargement d’assemblys, chacun avec son propre comportement :
- Le contexte de chargement par défaut, où les assemblys sont chargés par défaut
- Le contexte de chargement source, pour le chargement manuel des assemblys au moment de l’exécution
- Le contexte de réflexion uniquement, pour le chargement sécurisé d’assemblys afin de lire leurs métadonnées sans les exécuter
- Le mystérieux vide dans lequel résident les assemblys chargés avec
Assembly.LoadFile(string path)etAssembly.Load(byte[] asmBytes)
Pour plus d’informations, consultez Bonnes pratiques pour le chargement d’assemblys.
.NET Core (et .NET 5+) a remplacé cette complexité par un modèle plus simple :
- Aucun Global Assembly Cache. Les applications apportent toutes leurs propres dépendances. Cela supprime un facteur externe pour la résolution des dépendances dans les applications, ce qui rend la résolution des dépendances plus reproductible.
PowerShell, en tant qu’hôte de plug-in, complique légèrement cela pour les modules. Ses dépendances dans
$PSHOMEsont partagées avec tous les modules - Un seul domaine d’application et aucune possibilité d’en créer de nouveaux. Le concept de domaine d’application est conservé dans .NET comme état global du processus .NET.
- Un nouveau modèle de contexte de chargement d’assemblys extensible. La résolution d’assembly peut être limitée à un espace de noms en la plaçant dans un nouvel ALC. Les processus .NET commencent avec un seul contexte de chargement d’assemblys par défaut, dans lequel tous les assemblys sont chargés (à l’exception de ceux chargés avec
Assembly.LoadFile(string)etAssembly.Load(byte[])). Le processus peut cependant créer et définir ses propres contextes de chargement d’assemblys personnalisés avec sa propre logique de chargement. Quand un assembly est chargé, le premier contexte de chargement d’assemblys dans lequel il est chargé est responsable de la résolution de ses dépendances. Cela permet d’implémenter de puissants mécanismes de chargement de plug-ins .NET
Dans les deux implémentations, les assemblys sont chargés tardivement. Cela signifie qu’ils sont chargés quand une méthode nécessitant leur type est exécutée pour la première fois.
Voici par exemple deux versions du même code qui chargent une dépendance à différents moments.
La première charge toujours sa dépendance lorsque Program.GetRange() est appelée, car la référence de dépendance est présente lexicalement dans la méthode :
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;
}
}
La seconde charge sa dépendance seulement si le paramètre limit est supérieur ou égal à 20, en raison de l’indirection interne via une méthode :
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();
}
}
Il s’agit d’une bonne pratique, car elle réduit la mémoire et les E/S de système de fichiers, et utilise plus efficacement les ressources. Malheureusement, l’un des effets secondaires est que nous ne savons pas que le chargement de l’assembly a échoué tant que nous n’avons pas atteint le chemin du code qui tente de charger l’assembly.
Cela peut également créer une condition de minutage pour les conflits de chargement d’assembly. Si deux parties du même programme tentent de charger différentes versions du même assembly, la version chargée dépend du chemin du code exécuté en premier.
Pour PowerShell, cela signifie que les facteurs suivants peuvent affecter un conflit de chargement d’assembly :
- Quel a été le module chargé en premier ?
- Le chemin du code qui utilise la bibliothèque de dépendances a-t-il été exécuté ?
- PowerShell charge-t-il une dépendance en conflit au démarrage ou uniquement sous certains chemins du code ?
Correctifs rapides et limitations
Dans certains cas, il est possible d’effectuer de petits ajustements sur votre module et de résoudre les problèmes avec un minimum d’effort. Toutefois, ces solutions ont tendance à présenter des inconvénients. Même si elles peuvent s’appliquer à votre module, elles ne fonctionneront pas pour chaque module.
Changer votre version de dépendance
Le moyen le plus simple d’éviter les conflits de dépendance consiste à s’accorder sur une dépendance. Cela peut être possible dans les cas suivants :
- Votre conflit concerne une dépendance directe de votre module et vous contrôlez la version
- Votre conflit concerne une dépendance indirecte, mais vous pouvez configurer vos dépendances directes de façon à utiliser une version de dépendance indirecte exploitable
- Vous connaissez la version en conflit et vous pouvez vous fier à ce qu’elle ne change pas
Le package Newtonsoft.Json est un bon exemple de ce dernier scénario. Il s’agit d’une dépendance de PowerShell 6 et versions ultérieures, et il n’est pas utilisé dans Windows PowerShell. Cela signifie qu’un moyen simple de résoudre les conflits de gestion de versions consiste à cibler la version la plus ancienne de Newtonsoft.Json sur les versions de PowerShell que vous souhaitez cibler.
Par exemple, PowerShell 6.2.6 et PowerShell 7.0.2 utilisent actuellement Newtonsoft.Json version 12.0.3. Pour créer un module ciblant Windows PowerShell, PowerShell 6 et PowerShell 7, vous devez cibler Newtonsoft.Json 12.0.3 en tant que dépendance et l’inclure dans votre module créé. Quand le module est chargé dans PowerShell 6 ou 7, le propre assembly Newtonsoft.Json de PowerShell est déjà chargé. Étant donné qu’il s’agit de la version requise pour votre module, la résolution réussit. Dans Windows PowerShell, l’assembly n’est pas déjà présent dans PowerShell : il est donc chargé à la place depuis le dossier de votre module.
En général, quand vous ciblez un package PowerShell concret, comme Microsoft.PowerShell.Sdk ou System.Management.Automation, NuGet doit être en mesure de résoudre les bonnes versions des dépendances requises. Cibler à la fois Windows PowerShell et PowerShell 6+ devient plus difficile, car vous devez choisir entre cibler plusieurs frameworks ou PowerShellStandard.Library.
Voici quelques cas où l’épinglage sur une version de dépendance commune ne fonctionnera pas :
- Le conflit concerne une dépendance indirecte, et aucune de vos dépendances ne peut être configurée pour utiliser une version commune
- L’autre version de dépendance est susceptible de changer souvent. Par conséquent, le fait de se fixer sur une version commune n’est qu’un correctif à court terme
Utiliser la dépendance hors processus
Cette solution concerne davantage les utilisateurs de module que les auteurs de module. Elle convient lorsque vous êtes confronté à un module qui ne fonctionne pas en raison d’un conflit de dépendance existant.
Les conflits de dépendances se produisent car deux versions du même assembly sont chargées dans le même processus .NET. Une solution simple consiste à les charger dans des processus différents, à condition que vous puissiez toujours utiliser les fonctionnalités des deux processus ensemble.
Dans PowerShell, il existe plusieurs façons d’y parvenir :
Appeler PowerShell en tant que sous-processus
Pour exécuter une commande PowerShell en dehors du processus actif, démarrez un nouveau processus PowerShell directement avec l’appel de commande :
pwsh -c 'Invoke-ConflictingCommand'La limitation principale ici est que la restructuration du résultat peut être plus délicate ou plus sujette aux erreurs que d’autres options.
Système de travail PowerShell
Le système de travail PowerShell exécute également des commandes hors processus, en envoyant des commandes à un nouveau processus PowerShell et en retournant les résultats :
$result = Start-Job { Invoke-ConflictingCommand } | Receive-Job -WaitDans ce cas, vous devez simplement vous assurer que les variables et l’état sont transmis correctement.
Le système de travail peut également être un peu lourd quand vous exécutez des commandes de petite taille.
Communication à distance PowerShell
Lorsqu’elle est disponible, la communication à distance PowerShell peut être un moyen pratique d’exécuter des commandes hors processus. Avec elle, vous pouvez créer une nouvelle PSSession dans un nouveau processus, appeler ses commandes via la communication à distance PowerShell, puis utiliser les résultats localement avec les autres modules contenant les dépendances en conflit.
Voici un exemple :
# 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-ConflictingCommandCommunication à distance implicite vers Windows PowerShell
Dans PowerShell 7, une autre option consiste à utiliser l’indicateur
-UseWindowsPowerShellsurImport-Module. Ceci importe le module via une session de communication à distance locale dans Windows PowerShell :Import-Module -Name ConflictingModule -UseWindowsPowerShellNotez que les modules peuvent ne pas être compatibles avec Windows PowerShell ou fonctionner différemment.
Lorsque l’appel hors processus ne doit pas être utilisé
En tant qu’auteur de module, l’appel de commande hors processus est difficile à intégrer dans un module et peut présenter des cas litigieux susceptibles de provoquer des problèmes. En particulier, la communication à distance et les travaux peuvent ne pas être disponibles dans tous les environnements où votre module doit fonctionner. Toutefois, le principe général consistant à déplacer l’implémentation hors processus et à permettre au module PowerShell d’être un client allégé peut toujours s’appliquer.
En tant qu’utilisateur de module, il existe des cas où l’appel hors processus ne fonctionnera pas :
- Lorsque la communication à distance PowerShell n’est pas disponible parce que vous n’avez pas de privilèges pour l’utiliser ou qu’elle n’est pas activée.
- Lorsqu’un type .NET particulier doit être généré en tant qu’entrée d’une méthode ou d’une autre commande. Les commandes qui s’exécutent via la communication à distance PowerShell émettent des objets désérialisés plutôt que des objets .NET fortement typés. Cela signifie que les appels de méthode et les API fortement typées ne fonctionnent pas avec la sortie des commandes importées via la communication à distance.
Solutions plus robustes
Avec toutes les solutions précédentes, certains scénarios et modules ne fonctionnent pas. Elles ont toutefois l’avantage d’être relativement simples à implémenter correctement. Les solutions suivantes sont plus robustes, mais leur implémentation correcte nécessite davantage d’efforts et elles peuvent introduire des bogues subtils si elles ne sont pas écrites avec précaution.
Chargement par le biais de contextes de chargement d’assembly .NET Core
Les contextes de chargement d’assemblys (ALC) ont été introduits dans .NET Core 1.0 afin de répondre spécifiquement à la nécessité de charger plusieurs versions du même assembly dans le même runtime.
Dans .NET, ils offrent la solution la plus robuste au problème de chargement de versions en conflit d’un assembly. Toutefois, les alcs personnalisés ne sont pas disponibles dans .NET Framework. Cela signifie que cette solution fonctionne uniquement dans PowerShell 6 et versions ultérieures.
Actuellement, le meilleur exemple d’utilisation d’un ALC pour l’isolation des dépendances dans PowerShell se trouve dans PowerShell Editor Services, le serveur de langage pour l’extension PowerShell pour Visual Studio Code. Un ALC est utilisé pour empêcher que les propres dépendances de PowerShell Editor Services entrent en conflit avec celles des modules PowerShell.
L’implémentation de l’isolation des dépendances de module avec un ALC est conceptuellement difficile, mais nous allons examiner un exemple minimal. Imaginez que nous avons un module simple destiné uniquement à fonctionner dans PowerShell 7. Le code source est organisé comme suit :
+ AlcModule.psd1
+ src/
+ TestAlcModuleCommand.cs
+ AlcModule.csproj
L’implémentation de l’applet de commande ressemble à ceci :
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!");
}
}
}
Le manifeste (très simplifié) ressemble à ceci :
@{
Author = 'Me'
ModuleVersion = '0.0.1'
RootModule = 'AlcModule.dll'
CmdletsToExport = @('Test-AlcModule')
PowerShellVersion = '7.0'
}
Et le csproj ressemble à ceci :
<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>
Lorsque nous générons ce module, la sortie générée présente la disposition suivante :
AlcModule/
+ AlcModule.psd1
+ AlcModule.dll
+ Shared.Dependency.dll
Dans cet exemple, notre problème se trouve dans l’assembly Shared.Dependency.dll, qui est notre dépendance en conflit imaginaire. Il s’agit de la dépendance que nous devons placer derrière un ALC afin de pouvoir utiliser la version propre au module.
Nous devons remanier le module afin que :
- Les dépendances de module soient uniquement chargées dans notre ALC personnalisé, et non dans l’ALC de PowerShell, afin qu’il ne puisse y avoir aucun conflit. De plus, à mesure que nous ajoutons des dépendances à notre projet, nous ne souhaitons pas ajouter continuellement du code pour que le chargement continue à fonctionner. Au lieu de cela, nous souhaitons avoir une logique de résolution de dépendance générique et réutilisable.
- Le chargement du module fonctionne toujours normalement dans PowerShell. Les applets de commande et autres types requis par le système de module PowerShell sont définis dans le propre ALC de PowerShell.
Pour répondre à ces deux exigences, nous devons diviser notre module en deux assemblys :
- Un assembly d’applet de commande,
AlcModule.Cmdlets.dll, qui contient les définitions de tous les types dont le système de module de PowerShell a besoin pour charger correctement notre module. À savoir, toutes les implémentations de la classe de baseCmdletet la classe qui implémenteIModuleAssemblyInitializer, qui configure le gestionnaire d’événements pourAssemblyLoadContext.Default.Resolvingafin de charger correctementAlcModule.Engine.dllpar le biais de notre ALC personnalisé. Comme PowerShell 7 masque délibérément les types définis dans les assemblys chargés dans d’autres ALC, tous les types qui sont censés être exposés publiquement à PowerShell doivent également être définis ici. Pour finir, notre définition d’ALC personnalisée doit être définie dans cet assembly. À part cela, le moins de code possible doit résider dans cet assembly. - Un assembly de moteur,
AlcModule.Engine.dll, qui gère l’implémentation réelle du module. Ses types sont disponibles dans l’ALC PowerShell, mais il est initialement chargé via notre ALC personnalisé. Ses dépendances sont chargées uniquement dans l’ALC personnalisé. Il assume en fait le rôle de pont entre les deux ALC.
Avec ce concept de pont, notre nouvelle disposition d’assembly ressemble à ceci :
diagramme 
Pour être sûr que la logique de sondage des dépendances de l’ALC par défaut ne résout pas les dépendances à charger dans l’ALC personnalisé, nous devons séparer ces deux parties du module dans des répertoires différents. La nouvelle disposition de module présente la structure suivante :
AlcModule/
AlcModule.Cmdlets.dll
AlcModule.psd1
Dependencies/
| + AlcModule.Engine.dll
| + Shared.Dependency.dll
Pour voir comment l’implémentation change, nous allons commencer par l’implémentation de AlcModule.Engine.dll :
using Shared.Dependency;
namespace AlcModule.Engine
{
public class AlcEngine
{
public static void Use()
{
Dependency.Use();
}
}
}
Il s’agit d’un conteneur simple pour la dépendance, Shared.Dependency.dll, mais vous devez le considérer comme l’API .NET pour votre fonctionnalité que les applets de commande dans l’autre assembly encapsulent pour PowerShell.
L’applet de commande dans AlcModule.Cmdlets.dll se présente comme suit :
// 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!");
}
}
}
À ce stade, si nous chargeons AlcModule et que nous exécutons Test-AlcModule, nous obtenons une FileNotFoundException quand l’ALC par défaut tente de charger Alc.Engine.dll pour exécuter EndProcessing(). C’est bien, car cela signifie que l’ALC par défaut ne peut pas trouver les dépendances que nous souhaitons masquer.
À présent, nous devons ajouter du code à AlcModule.Cmdlets.dll afin qu’elle sache comment résoudre AlcModule.Engine.dll. Tout d’abord, nous devons définir notre ALC personnalisé pour résoudre les assemblys du répertoire Dependencies de notre module :
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;
}
}
}
Nous devons ensuite associer notre ALC personnalisé à l’événement Resolving de l’ALC par défaut, qui est la version d’ALC de l’événement AssemblyResolve sur les domaines d’application. Cet événement est déclenché pour rechercher AlcModule.Engine.dll lorsque EndProcessing() est appelée.
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);
}
}
}
Avec la nouvelle implémentation, examinez la séquence d’appels qui se produit lorsque le module est chargé et que Test-AlcModule est exécutée :
Voici quelques points intéressants :
- L’
IModuleAssemblyInitializerest exécuté en premier lorsque le module est chargé et définit l’événementResolving. - Nous ne chargeons les dépendances que lorsque
Test-AlcModuleest exécuté et que sa méthodeEndProcessing()est appelée. - Lorsque
EndProcessing()est appelée, l’ALC par défaut ne parvient pas à trouverAlcModule.Engine.dllet il déclenche l’événementResolving. - Notre gestionnaire d’événements raccorde l’ALC personnalisé à l’ALC par défaut, et charge uniquement
AlcModule.Engine.dll. - Lorsque
AlcEngine.Use()est appelée dansAlcModule.Engine.dll, l’ALC personnalisé intervient afin de résoudreShared.Dependency.dll. Plus précisément, il charge toujours notreShared.Dependency.dll, car il n’entre jamais en conflit avec quoi que ce soit dans l’ALC par défaut et ne recherche que dans notre répertoireDependencies.
En assemblant l’implémentation, la nouvelle disposition du code source ressemble à ceci :
+ AlcModule.psd1
+ src/
+ AlcModule.Cmdlets/
| + AlcModule.Cmdlets.csproj
| + TestAlcModuleCommand.cs
| + AlcModuleAssemblyLoadContext.cs
| + AlcModuleInitializer.cs
|
+ AlcModule.Engine/
| + AlcModule.Engine.csproj
| + AlcEngine.cs
AlcModule.Cmdlets.csproj ressemble à ceci :
<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 ressemble à ceci :
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Shared.Dependency" Version="1.0.0" />
</ItemGroup>
</Project>
Ainsi, lorsque nous créons le module, notre stratégie est la suivante :
- Générer
AlcModule.Engine - Générer
AlcModule.Cmdlets - Copier tout le contenu de
AlcModule.Enginedans le répertoireDependencieset nous souvenir de ce que nous avons copié - Copier tout le contenu de
AlcModule.Cmdletsqui n’était pas dansAlcModule.Enginedans le répertoire du module de base
La disposition du module étant ici si cruciale pour la séparation des dépendances, voici un script de génération à utiliser à partir de la racine source :
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 }
Enfin, nous avons un moyen général d’isoler les dépendances de notre module dans un contexte de chargement d’assembly qui reste robuste au fil du temps, car d’autres dépendances sont ajoutées.
Pour obtenir un exemple plus détaillé, accédez à ce dépôt GitHub. Cet exemple montre comment migrer un module afin qu’il utilise un ALC, tout en veillant à ce qu’il reste opérationnel dans le .NET Framework. Il montre également comment utiliser .NET Standard et PowerShell Standard pour simplifier l’implémentation de base.
Cette solution est également utilisée par le module Bicep PowerShell . Vous pouvez lire le billet de blog Résolution des conflits de modules PowerShell pour en savoir plus sur cette solution.
Gestionnaire de résolution d’assembly pour le chargement côte à côte
Bien qu’elle soit robuste, la solution décrite ci-dessus nécessite que l’assembly du module ne référence pas directement les assemblys de dépendance, mais qu’il référence à la place un assembly de type « wrapper » qui référence à son tour les assemblys de dépendance. L’assembly de type « wrapper » agit comme un pont, en transférant les appels de l’assembly de module aux assemblys de dépendance. Adopter cette solution demande généralement une quantité de travail non négligeable :
- Pour un nouveau module, cela ajoute une complexité supplémentaire à la conception et à l’implémentation
- Pour un module existant, cela demande une refactorisation importante
Il existe une solution simplifiée pour obtenir un chargement d’assembly côte à côte, en raccordant un événement Resolving à une instance AssemblyLoadContext personnalisée. L’utilisation de cette méthode est plus facile pour l’auteur du module, mais présente deux limitations. Modifiez le référentiel PowerShell-ALC-Samples pour obtenir des exemples de code et la documentation qui décrit les limitations et scénarios détaillés de cette solution.
Important
N’utilisez Assembly.LoadFile pas pour l’isolation des dépendances. L’utilisation de Assembly.LoadFile crée un problème d’identité de type quand un autre module charge une version différente du même assembly dans la valeur par défaut AssemblyLoadContext. Bien que cette API charge un assembly dans une instance AssemblyLoadContext distincte, les assemblys chargés sont détectables par le code de résolution de type de PowerShell. Par conséquent, il peut y avoir des types en double avec le même nom de type entièrement qualifié disponible à partir de deux ALC différents.
Domaines d’application personnalisés
La dernière option (et la plus extrême) pour l’isolation d’assembly consiste à utiliser des domaines d’application personnalisés. Les domaines d’application sont disponibles seulement dans .NET Framework. Ils sont utilisés pour fournir une isolation in-process entre les parties d’une application .NET. L’une des utilisations consiste à isoler les charges d’assembly les unes des autres au sein du même processus.
Cependant, les domaines d’application sont des limites de sérialisation. Les objets d’un domaine d’application ne peuvent pas être référencés et utilisés directement par les objets d’un autre domaine d’application. Vous pouvez contourner ce problème en implémentant MarshalByRefObject. Toutefois, lorsque vous ne contrôlez pas les types, comme c’est souvent le cas avec les dépendances, il n’est pas possible de forcer une implémentation ici. La seule solution consiste à apporter des modifications architecturales importantes. La limite de sérialisation a également de sérieuses implications en matière de performances.
Étant donné que les domaines d’application ont cette importante limitation, ils sont compliqués à implémenter et ne fonctionnent que dans le .NET Framework ; nous ne donnerons pas d’exemple de la façon dont vous pouvez les utiliser ici. Bien qu’ils méritent d’être mentionnés en tant que possibilité, leur utilisation n’est pas recommandée.
Si vous souhaitez essayer d’utiliser un domaine d’application personnalisé, les liens suivants peuvent vous aider :
- Documentation conceptuelle sur les domaines d’application
- Exemples d’utilisation de domaines d’application
Solutions aux conflits de dépendances qui ne fonctionnent pas pour PowerShell
Pour finir, nous allons aborder certaines possibilités qui peuvent sembler prometteuses lors de l’examen des conflits de dépendances .NET dans .NET, mais qui ne fonctionnent généralement pas pour PowerShell.
Ces solutions ont le thème commun qu’elles modifient les configurations de déploiement pour un environnement où vous contrôlez l’application et éventuellement l’ordinateur entier. Elles sont orientées vers des scénarios tels que des serveurs web et d’autres applications déployées dans des environnements de serveur, où l’environnement est destiné à héberger l’application et peut être configuré par l’utilisateur du déploiement. Elles ont également tendance à être très orientées vers le .NET Framework, ce qui signifie qu’elles ne fonctionnent pas avec PowerShell 6 ou ultérieur.
Si vous savez que votre module est utilisé uniquement dans des environnements Windows PowerShell 5.1 que vous contrôlez entièrement, certaines d’entre elles peuvent être des options envisageables. Cependant, en règle générale, les modules ne doivent pas modifier l’état global de la machine comme ceci. Cela peut rompre des configurations qui provoquent des problèmes dans powershell.exe, d’autres modules ou d’autres applications dépendantes qui entraînent l’échec inattendu de votre module.
Redirection de liaison statique avec app.config pour forcer l’utilisation de la même version de dépendance
Les applications .NET Framework peuvent tirer parti d’un fichier app.config pour configurer de façon déclarative certains comportements de l’application. Il est possible d’écrire une entrée app.config qui configure la liaison d’assembly de façon à rediriger le chargement d’assembly vers une version particulière.
Avec PowerShell, cela pose deux problèmes :
- .NET Core ne prend pas en charge
app.config: cette solution s’applique donc seulement àpowershell.exe. -
powershell.exeest une application partagée qui réside dans le répertoireSystem32. Il est probable que votre module ne pourra pas modifier son contenu sur de nombreux systèmes. Même s’il le peut, la modification du fichierapp.configrisque d’invalider une configuration existante ou d’affecter le chargement d’autres modules
Définition de codebase avec app.config
Pour les mêmes raisons, la tentative de configuration du codebase paramètre dans app.config ne fonctionnera pas dans les modules PowerShell.
Installation de dépendances dans le Global Assembly Cache (GAC)
Une autre façon de résoudre les conflits de version de dépendance dans le .NET Framework consiste à installer les dépendances dans le GAC, afin que différentes versions puissent être chargées côte à côte à partir du GAC.
Là encore, pour les modules PowerShell, les principaux problèmes sont les suivants :
- Le GAC s’applique uniquement au .NET Framework. Cela n’est donc pas utile dans PowerShell 6 et versions ultérieures.
- L’installation d’assemblys dans le GAC est une modification de l’état global de la machine susceptible d’avoir des effets secondaires dans d’autres applications ou modules. Elle peut également être difficile à accomplir correctement, même si votre module dispose des privilèges d’accès requis. Toute erreur peut provoquer des problèmes sérieux à l’échelle de la machine dans d’autres applications .NET.
Pour aller plus loin
Il existe de nombreux articles qui traitent des conflits de dépendances de version d’assembly .NET. En voici une sélection :
- .NET : Assemblys dans .NET
- .NET Core : Algorithme de chargement d’assembly managé
- .NET Core : Comprendre System.Runtime.Loader.AssemblyLoadContext
- .NET Core : Discussion relative aux solutions de chargement d’assemblys côte à côte
- .NET Framework : Redirection des versions d’assemblys
- .NET Framework : Bonnes pratiques pour le chargement d’assembly
- .NET Framework : Méthode de localisation des assemblys par le runtime
- .NET Framework : Résoudre les chargements d’assemblys
- Stack Overflow : Redirection de liaison d’assembly, comment et pourquoi ?
- PowerShell : Discussion relative à l’implémentation d’AssemblyLoadContexts
-
PowerShell :
Assembly.LoadFile()ne se charge pas dans l’AssemblyLoadContext par défaut - Rick Strahl : When does a .NET assembly dependency get loaded? (Quand une dépendance d’assembly .NET est-elle chargée ?)
- Jon Skeet : Summary of versioning in .NET (Récapitulatif de la gestion de versions dans .NET)
- Nate McMaster : Deep dive into .NET Core primitives (Immersion dans les primitives .NET Core)