Compatibilité entre le framework .NET historique et .NET


Actuellement, 2 implémentations de .NET peuvent être utilisées:

  • l’implémentation historique du Framework .NET toujours supportée mais dont la version s’arrête à la version majeure 4.8 (actuellement 4.8.1).
  • l’implémentation actuelle .NET anciennement appelée .NET Core jusqu’à la version 3.1 (cf. dotnet.microsoft.com/en-us/download/dotnet).

Le framework .NET existant depuis les années 2000, beaucoup d’applications existent toujours en ayant comme cible le framework historique. Même si la grande majorité des fonctionnalités existent entre le framework .NET et .NET, le passage d’un framework à l’autre est loin d’être trivial. Des solutions existent pour limiter la quantité de code ciblant le framework .NET comme par exemple utiliser .NET Standard. En effet, .NET Standard permet ainsi d’avoir du code utilisable à la fois par des applications ciblant le framework .NET et .NET. Toutefois, de la même façon le passage du code du framework .NET à .NET Standard est loin d’être trivial.

Ainsi dans le but de ne pas avoir à trop migrer du code “legacy”, on peut se poser la question de savoir quelles sont les compatibilités entre le framework .NET historique et l’implémentation plus actuelle de .NET.

Le but de cet article est d’étudier en détails les différences entre le framework .NET et .NET et vérifier quels sont les éléments de compatibilité entre les 2:

  • Quelles sont les différences entre une assembly ciblant le framework .NET et une assembly ciblant .NET sous Windows ?
  • Une assembly ciblant framework .NET peut-elle être utilisée directement dans une application .NET ? Et inversement ?
  • Est-on obligé d’utiliser .NET Standard pour avoir des assemblies communes entre le framework .NET et .NET ?
.NET et .NET 🤯

Dans cet article pour éviter les confusions, on emploiera les termes:

  • “Framework .NET historique” pour désigner le framework .NET dont la version s’arrête à 4.8.x.
  • “.NET” pour désigner l’implémentation actuelle de .NET (anciennement appelée .NET Core).

Dans un premier temps, on va indiquer quelles sont les différences les plus importantes entre une application ciblant le framework .NET historique et la même application ciblant .NET. Ensuite, on va montrer quelles sont les possibilités pour utiliser des assemblies communes sans forcément utiliser .NET Standard.

Fonctionnement générale de .NET

Avant tout, le but de cette partie est de décrire le fonctionnement générale de .NET suivant les 2 implémentations.

Historique de .NET

Framework .NET historique

Historiquement le framework .NET historique a été construit au dessus de Windows ce qui le rend très spécifique à cette plateforme. On peut, toutefois, distinguer 2 types de dépendances sur lesquelles repose une application ciblant le framework .NET historique:

  • Les dépendances systèmes: ensemble de DLL Windows permettant à l’application de s’interfacer avec le système d’exploitation et plus généralement la machine, par exemple: advapi32.dll, kernel32.dll, gdi32.dll etc… (on peut trouver une liste plus exhautive sur Microsoft Windows library files).
  • Le framework .NET historique: avant Windows 10, le framework était distribué de façon séparé du système d’exploitation (cf. dotnet.microsoft.com/en-us/download/dotnet-framework). Il fallait donc l’installer séparément. Depuis Windows 10, le framework fait partie du système d’exploitation et est distribué en même temps sans installation séparée. On peut toutefois considérer les DLL et assemblies du framework .NET historique séparément des DLL système. L’assembly la plus connue faisant partie de cette catégorie est mscorlib.dll.

Historiquement et en particulier avant Nuget (apparu avec le framework 4.0), une application ciblant le framework .NET historique était distribuée sans aucune assembly du framework, seul le code et les assemblies fonctionnelles étaient fournis. De façon à rendre une application moins dépendante de la version du framework installée sur une machine, lors de l’implémentation d’une application, certaines dépendances du framework pouvaient être téléchargées via Nuget à partir du framework 4.0. Ainsi, on peut trouver des packages Nuget comme System.Runtime, System.IO, System.IO.FileSystem etc… Les assemblies dans ces packages sont déployées au même moment que l’application ce qui permet de rendre l’application moins dépendante des assemblies .NET du système d’exploitation.

Le framework .NET historique est toujours supporté toutefois il ne bénéficie plus plus d’évolutions fonctionnelles notables. La dernière version est 4.8.1. Il existe des compatibilités de code avec .NET Core.

.NET Core

L’implémentation .NET Core a commencé en 2016 de la version .NET Core 1.0 jusqu’à la version 3.1. Cette implémentation continue actuellement sous l’appellation .NET. Le plus gros avantage de cette implémentation est qu’elle n’est pas dépendante de la plateforme Windows à l’inverse du framework .NET historique. A la compilation, il est possible de cibler d’autres systèmes d’exploitation comme Linux ou Mac OS. De plus, il est possible de déployer une application .NET Core de façon autonome avec toutes les dépendances dans le même répertoire ce qui limite d’éventuel problème de dépendances non satisfaites à l’exécution.

Au fur et à mesure des versions de .NET Core, les fonctionnalités du framework .NET historique ont été portées sur .NET Core:

  • .NET Core 1.0: portage des fonctionnalités principales du framework .NET historique avec la CoreCLR et la machine virtuelle permettant l’exécution des application .NET.
  • .NET Core 2.0: supporte .NET Standard 2.0. Beaucoup de fonctionnalités sont rajoutés au .NET Standard 2.0 et donc de fait sont rajoutées dans .NET Core 2.0 comme ASP.NET Core 2.0, Entity Framework Core 2.0, Razor Pages, SignalR.
  • .NET Core 3.0: le support de technologie desktop comme WPF ou Windows Forms est rajoutée mais réservé à une plateforme Windows. .NET Core 3.0 introduit des fonctionnalités inédites comme les Web assemblies.

La dénomination .NET Core s’est arrêtée avec la version 3.1 toutefois cette implémentation a continué sous l’appélation .NET.

.NET

Il s’agit actuellement de l’implémentation principale de la technologie .NET. L’appellation a commencé avec la version 5.0 qui est la continuation de .NET Core. Une très grande partie des fonctionnalités du framework .NET historique ont été portées dans .NET. Désormais, les évolutions fonctionnelles ne sont implémentées que dans cette implémentation.

Dans Visual Studio 2022, l’interface ne permet plus de créer une application du framework .NET historique sans éditer directement le fichier .csproj.

Fonctionnement général du CLR

Le but de cette partie est d’évoquer quelques caractéristiques du CLR.

Chargement des assemblies à la demande

Par défaut, le CLR fonctionne en ne chargeant que les types dont il a besoin en mode “lazy loading” c’est-à-dire seulement s’il doit appeler une méthode dans ce type.

Par exemple si on considère le code suivant:

  • Une classe QuickSort se trouvant dans une assembly nommée QuickSort.dll:
    namespace Example
    {
      public class QuickSort
      {
        public QuickSort(int numberCount)
        {
          // ...
        }
    
        public IReadOnlyList<int> OriginalNumbers => this.originalNumbers;
        public IReadOnlyList<int> Sort()
        {
          // ...
        }
      }
    }
    

    Le détail du code n’a pas d’importance, il faut juste prêter attention aux appels qui sont effectués.

  • Une classe QuickSortCaller se trouvant dans une assembly nommée Launcher.dll:
    namespace Launcher
    {
      internal class QuickSortCaller
      {
        public QuickSortCaller()
        {
    
        }
    
        public void CallQuickSort() 
        {
          var quickSort = new QuickSort(10);
          Console.WriteLine($"Original numbers: {string.Join("; ", quickSort.OriginalNumbers)}");
          var sortedNumbers = quickSort.Sort();
          Console.WriteLine($"Sorted numbers: {string.Join("; ", sortedNumbers)}");
        }
      }
    }
    
  • Enfin le "Main" dans l’assembly Launcher.dll:
    namespace Example.Launcher
    {
      class TestClass
      {
        static void Main(string[] args)
        {
          PrintAssemblies();
          Console.ReadLine();
    
          var quickSortCaller = new QuickSortCaller();
          quickSortCaller.CallQuickSort();
    
          PrintAssemblies();
          Console.ReadLine();
        }
    
        public static void PrintAssemblies()
        {
          var assemblies = AppDomain.CurrentDomain.GetAssemblies();
          foreach (var assembly in assemblies)
          {
            Console.WriteLine(assembly.GetName());
          }
        }
      }
    }
    

Le but de cette application est de lancer le tri en ordre croissant de 10 nombres par la classe QuickSort à partir de Launcher.exe. L’exécution est interrompue par des Console.ReadLine() de façon à pouvoir vérifier quelles sont les assemblies qui sont chargées en mémoire.

On se propose de monitorer le chargement des assemblies avec Process Monitor (on peut télécharger cet outil sur learn.microsoft.com/en-us/sysinternals/downloads/procmon).
On le configure en cliquant sur le filtre:

Filtrer dans Process Monitor

On applique les filtres:

  • Process Name is Launcher.exe.
  • Operation is "Load Image".

Ainsi:

  • A la 1ere exécution de Console.ReadLine(), on obtient à l’exécution:
    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.exe
    System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
    Launcher, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
    System.Runtime, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Console, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    

    Dans Process Monitor, on obtient la liste:

    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.exe
    C:\Windows\System32\ntdll.dll
    C:\Windows\System32\kernel32.dll
    C:\Windows\System32\KernelBase.dll
    C:\Windows\System32\user32.dll
    C:\Windows\System32\win32u.dll
    C:\Windows\System32\gdi32.dll
    C:\Windows\System32\gdi32full.dll
    C:\Windows\System32\msvcp_win.dll
    C:\Windows\System32\ucrtbase.dll
    C:\Windows\System32\shell32.dll
    C:\Windows\System32\advapi32.dll
    C:\Windows\System32\msvcrt.dll
    C:\Windows\System32\sechost.dll
    C:\Windows\System32\rpcrt4.dll
    C:\Windows\System32\imm32.dll
    C:\Program Files\dotnet\host\fxr\7.0.2\hostfxr.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\hostpolicy.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\coreclr.dll
    C:\Windows\System32\ole32.dll
    C:\Windows\System32\combase.dll
    C:\Windows\System32\oleaut32.dll
    C:\Windows\System32\bcryptprimitives.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\System.Private.CoreLib.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\clrjit.dll
    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.dll
    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.dll
    C:\Windows\System32\kernel.appcore.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\System.Console.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\System.Threading.dll
    
  • A la 2e exécution de Console.ReadLine(), on obtient à l’exécution:
    Original numbers: 417669634; 1956917617; 711754152; 278682087; 1764089466; 1626660450; 907923799; 1181956495; 1884345336; 1655709668
    Sorted numbers: 278682087; 417669634; 711754152; 907923799; 1181956495; 1626660450; 1655709668; 1764089466; 1884345336; 1956917617
    System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
    Launcher, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
    System.Runtime, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Console, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Threading, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Text.Encoding.Extensions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    QuickSort, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
    mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
    

Dans Process Monitor, les lignes suivantes sont rajoutées:

C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\QuickSort.dll
C:\Windows\System32\bcrypt.dll

Ainsi on peut voir que le chargement de l’assembly QuickSort.dll ne s’est fait que lorsqu’il y a eu un appel à une fonction se trouvant dans cette assembly.

Démarrage d’une application .NET

Le démarrage d’une application .NET ne suit pas tout à fait le même ordre dans le cas du framework .NET historique et de .NET:

  • Dans le cas du framework .NET historique, l’application est un exécutable (i.e. fichier .exe) qui ne peut être lancé que sur Windows. Le format de ce fichier correspond à un Portable Executable qui sera reconnu par le système d’exploitation. Ce fichier contient une petite partie de code natif qui sert d’amorce pour démarrer le CLR par l’intermédiaire de mscoree.dll. Toutes les dépendances principales du framework dans mscorlib.dll sont ensuite chargées. Le code .NET se trouve sous la forme de code IL que le CLR va compiler en code machine par l’intermédiaire du compilateur JIT. Le premier module compilé et exécuté est celui correspondant à la fonction main. Suivant l’exécution, les autres parties du code seront compilées et les dépendances chargées au besoin.
  • Pour .NET, le fichier exécutable dépend du système d’exploitation qui a été ciblé lors de la compilation (i.e. runtime packs). Cet exécutable est une application hôte (i.e. “app host”) native qui va servir d’amorce pour charger et exécuter des DLL ayant des responsabilités différentes:
    • hostfxr.dll: cette DLL va sélectionner le bon runtime permettant d’exécuter l’application .NET. Ce runtime dépend du runtime ciblé au moment de la compilation, du système d’exploitation et du runtime réellement installé.
    • hostpolicy.dll: regroupe toutes les stratégies pour charger le runtime, appliquer la configuration, résoudre les dépendances de l’application et appeler le runtime pour exécuter l’application.
    • coreclr.dll: c’est le CLR qui va exécuter le code .NET. Le comportement est ensuite similaire au framework .NET historique: le code .NET sous la forme de code IL est compilé au besoin par l’intermédiaire du compilateur JIT. Ce code est ensuite exécuté.

Chargement des dépendances

Le principe général de chargement des dépendances est le même entre le framework .NET historique et .NET. On distingue les assemblies du framework et les dépendances plus spécifiques au code exécuté ne faisant pas partie du framework. Dans le cas du framework .NET historique, beaucoup de classes de base se trouvent dans mscorlib.dll qui est chargée au démarrage de l’application. Pour .NET, les classes de base se trouvent dans System.Private.CoreLib.dll et dans d’autres assemblies (comme System.Runtime, System.Threading, etc…).

En dehors des classes et des assemblies de base du framework, les dépendances sont chargées par le CLR au besoin si le code exécuté le nécessite. Ainsi si l’exécution d’une portion de code fait appel à un type se trouvant dans une dépendance, cette dépendance doit être chargée en mémoire si ce n’est pas déjà le cas. Le code IL obtenu suite à la compilation indique l’assembly à partir de laquelle ce type peut être trouvé; c’est de cette façon que le CLR, à l’exécution, peut savoir où le récupérer. Par exemple dans le cadre de l’exemple précédent, si on regarde le code IL correspondant à l’appel au code se trouvant dans l’assembly QuickSort.dll:

.method public hidebysig instance void  CallQuickSort() cil managed
{
  // Code size     76 (0x4c)
  .maxstack  3
  .locals init (class [QuickSort]Example.QuickSort V_0,
       class [System.Runtime]System.Collections.Generic.IReadOnlyList`1<int32> V_1)
 //...
}

On peut voir que le classe Example.QuickSort se trouve dans l’assembly QuickSort. A partir des metadatas dans le manifest, on peut trouver les caractéristiques de QuickSort:

.assembly extern QuickSort
{
  .ver 1:0:0:0
}

Si l’assembly est signée par nom fort, les metadatas comportent la clé publique correspondant à la signature:

.assembly extern System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 6:0:0:0
}

Ainsi une dépendance externe est identifiée avec le nom de l’assembly, la version et éventuellement la clé publique. Ce fonctionnement est le même entre le framework .NET historique et .NET. En revanche, ce qui diffère est la façon dont les dépendances sont cherchées par le CLR. En effet, lorsque le CLR doit chercher une dépendance qui n’a jamais été chargée, une séquence est lancée pour chercher l’emplacement de cette dépendance.

Processus de recherche des assemblies avec le framework .NET historique

En plus de la version indiquée dans les metadatas du manifest du code IL, le CLR va chercher à déterminer la version de l’assembly à charger suivant des binding redirects pouvant se trouver dans:

  • Le fichier de configuration de l’application <nom exécutable>.exe.config.
  • Le fichier de stratégie de l’éditeur de l’assembly
  • Le fichier de configuration de la machine dans:
    • %windir%\Microsoft.NET\Framework\<version>\config\machine.config
    • %windir%\Microsoft.NET\Framework64\<version>\config\machine.config

Quand le CLR a déterminé la version à charger, il vérifie que l’assembly n’est pas déjà chargée. Si une version différente de l’assembly est déjà chargée, une exception de type FileLoadException est lancée. Si l’assembly possède un nom fort, il est possible d’en charger plusieurs versions (i.e. Side-by-side execution). Pour plus de détails sur la signature par nom fort, voir Signature des assemblies par nom fort en 5 min.

Si l’assembly n’a pas déjà été chargée, une recherche est faite dans le GAC (i.e. Global Assembly Cache). Si le GAC ne permet pas d’obtenir l’assembly à charger, une recherche à d’autres emplacements appelée probing est effectuée (i.e. Default probing). Par défaut cette recherche est effectuée:

  • En vérifiant s’il n’existe pas un élément de configuration <codeBase>
  • Dans le répertoire de l’application ou dans un répertoire avec le même nom que l’assembly:
    • <répertoire de l'application>\<nom assembly>.dll
    • <répertoire de l'application>\<nom assembly>\<nom assembly>.dll
    • <répertoire de l'application>\<culture>\<nom assembly>.dll
    • <répertoire de l'application>\<culture>\<nom assembly>\<nom assembly>.dll
  • Dans les répertoires se trouvant dans l’élément de configuration privatePath modifiable avec le paramètre <probing> dans le fichier de configuration ou dans le code avec AppDomainSetup.PrivateBinPath.
Débugguer la recherche de dépendances avec “fuslog”

Il est possible de débugguer cette séquence de recherche de dépendance en utilisant fuslogvw.exe.

Processus de recherche des assemblies avec .NET

Avec .NET, le chargement des dépendances est différent du mécanisme utilisé pour le framework .NET historique. Avec le framework .NET historique, il existait un AppDomain par défaut dans lequel les assemblies étaient chargées. Quand une assembly était chargée dans un AppDomain, il n’était pas possible de la décharger toutefois il était possible de décharger l’AppDomain entier. Ainsi l’AppDomain constitue une frontière qui permet, par exemple, de charger la même assembly dans des versions différentes. Les appels d’un AppDomain à l’autre se faisait par sérialisation/désérialisation d’objets.

AssemblyLoadContext

Avec .NET (à partir de la version 5), l’objet AppDomain existe toujours toutefois il n’existe qu’un seul AppDomain et il n’est pas possible d’en créer un autre:
AppDomain.CreateDomain(...) mène à une erreur. Les contextes de chargement (i.e. loader context) permettent de remplacer les AppDomains en apportant des fonctionnalités supplémentaires:

  • L’objet représentant un contexte de chargement est AssemblyLoadContext
  • Les contextes de chargement sont nommés, et il n’y a pas de contexte courant comme pour les AppDomains. On peut utiliser la fonction AssemblyLoadContext.GetLoadContext(<assembly>) pour renvoyer le contexte utilisé pour une assembly donnée.
  • Les appels d’un contexte à l’autre ne sont pas très couteux.
  • On peut toujours déchargé un contexte de chargement de façon à décharger une assembly:
    var newLoadContext = new AssemblyLoadContext(name: <nom du contexte>, isCollectible: true);
    // isCollectible à true autorise à décharger le contexte par la suite.
    newLoadContext.LoadFromAssemblyPath(<chemin de l'assembly>);
    // ... 
    newLoadContext.Unload();
    
  • En dérivant de la classe AssemblyLoadContext et en surchargeant les fonctions:
    • Assembly Load(AssemblyName assemblyName) ou
    • IntPtr LoadUnmanagedDll (string unmanagedDllName)

    On peut implémenter un comportement particulier pour charger les dépendances d’une assembly (y compris les dépendances natives). On peut s’aider de la classe AssemblyDependencyResolver dans la résolution des dépendances.
    Voir albahari.com/nutshell/E8-CH18.aspx pour davantage de détails sur l’implémentation.

  • Les fonctions Assembly.Load(byte[]), Assembly.LoadFrom(filename) créent toujours un contexte de chargement séparé.
  • L’intérêt principal de AssemblyLoadContext est de proposer une isolation pour permettre de facilement choisir dans quel contexte les dépendances seront chargées et quel contexte sera utilisé pour accéder à la dépendance d’une assembly. On peut indiquer une portée avec using dans laquelle un autre contexte de chargement sera utilisé:
    var addonLoadContext = new AssemblyLoadContext(...);
    using (addonLoadContext.EnterContextualReflection())
    {
      var addonAssembly = Assembly.Load(<nom de l'assembly>);
    }
    

Recherche par défaut des assemblies

Lors de l’exécution de l’application par l’app host”, après avoir chargé l’assembly coreclr.dll et avant de donner le main au CoreCLR, “l’app host” affecte certaines propriétés pour indiquer des éléments de contexte à l’exécution:

  • TRUSTED_PLATFORM_ASSEMBLIES: liste des chemins des assemblies du framework (managées et natives).
  • APP_PATHS et APP_NI_PATHS: chemin de l’application
  • NATIVE_DLL_SEARCH_DIRECTORIES: liste des chemins des répertoires qui seront parcourus pour chercher les DLL natives.
  • PLATFORM_RESOURCE_ROOTS: liste des chemins des répertoires qui seront parcourus pour chercher les assemblies satellites (assemblies de ressources).
  • APP_CONTEXT_BASE_DIRECTORY: répertoire de base de l’application
  • APP_CONTEXT_DEPS_FILES: liste des fichiers contenant les dépendances, les données du contexte de compilation et les dépendances de compilation pour l’application.
  • FX_DEPS_FILE: liste des fichiers contenant les dépendances du framework.

La valeur de ces propriétés peut être obtenue avec:

AppContext.GetData(<nom de la propriété>);

Ces propriétés sont utilisées par le CLR pour trouver les dépendances managées ou natives lors de l’exécution de l’application. Par défaut, le contexte de chargement AssemblyLoadContext.Default parcourt les chemins indiqués dans les propriétés TRUSTED_PLATFORM_ASSEMBLIES et APP_PATHS.

Les assemblies satellites (assemblies de ressources) sont cherchées dans les répertoires indiquées par les propriétés PLATFORM_RESOURCE_ROOTS et APP_PATHS en ajoutant l’extension liée au nom de la culture:

  • <répertoire>\<culture>\<nom assembly>.dll
  • <répertoire>\<culture>\<nom assembly>\<nom assembly>.dll
Comment débugguer la recherche de dépendances ?

On peut voir des logs concernant la recherche des dépendances en affectant les variables d’environnement suivantes avant d’exécuter l’application:

  • COREHOST_TRACE=1: permet d’indiquer qu’on souhaite activer les logs.
  • COREHOST_TRACEFILE=out.txt: permet d’indiquer le fichier de sortie dans lequel on souhaite envoyer les logs.
  • COREHOST_TRACE_VERBOSITY=4: pour indiquer le niveau de logs (4 étant le maximum).

Pour ajouter ces variables lors du débug de l’application dans Visual Studio:

  1. Debug<nom du projet> Debug Properties
  2. Rajouter les variables dans la partie Environment Variables

Dans le cas d’une exécution à la ligne de commandes, on peut créer un .bat contenant:

set COREHOST_TRACE=1
set COREHOST_TRACEFILE=out.txt
set COREHOST_TRACE_VERBOSITY=4

Launcher.exe

Redirection de type

La fonctionnalité de “type forwarding” (i.e. redirection de type) permet de déplacer un type d’une assembly à une autre sans avoir à changer les références des assemblies utilisant ce type. Cette fonctionnalité est régulièrement utilisée par Microsoft lors des changements de framework pour assurer la retrocompatibilité: par exemple lorsque des types ont été déplacés de System.Core.dll vers mscorlib.dll entre les frameworks 3.5 et 4 ou plus récemment lorsque des types de mscorlib.dll sont déplacés dans System.Private.CoreLib entre les frameworks .NET 4.8 et .NET Core.

Cette fonctionnalité utilise l’attribut TypeForwardToAttribute pour indiquer vers quelle assembly le type est transféré.

Pour illustrer, on prend l’exemple d’une application permettant d’appliquer l’algorithme “Quick sort” à une liste d’entiers. Le but n’est pas de rentrer dans les détails de l’algorithme mais d’avoir un exemple d’appel de fonctions. Ainsi:

  • L’algorithme se trouve dans une assembly nommée QuickSort:
    namespace Example;
    
    public class QuickSort
    {
      private readonly int[] originalNumbers;
      public QuickSort(int numberCount)
      {
        this.originalNumbers = new int[numberCount];
        Random rnd = new Random();
    
        for (int i = 0; i < numberCount; i++)
        {
          originalNumbers[i] = rnd.Next();
        }
      }
    
      public IReadOnlyList<int> OriginalNumbers => this.originalNumbers;
      public IReadOnlyList<int> Sort()
      {
      // ...
      }
    }
    
  • L’algorithme est appelé à partir d’une autre assembly nommée Launcher.exe:
    using System;
    using Launcher;
    
    namespace Example.Launcher
    {
      class TestClass
      {
        static void Main(string[] args)
        {
          var quickSort = new QuickSort(10);
          Console.WriteLine($"Original numbers: {string.Join("; ", quickSort.OriginalNumbers)}");
          var sortedNumbers = quickSort.Sort();
          Console.WriteLine($"Sorted numbers: {string.Join("; ", sortedNumbers)}");
        }
      }
    }
    
Code source

Le code source complet de cet exemple se trouve dans le repository GitHub github.com/msoft/NetCoreVsFramework.

Pour résumer Launcher.exe appelle une classe et une fonction dans QuickSort.dll. Ainsi Launcher.exe référence QuickSort.dll. On décide de déplacer le code dans QuickSort.dll dans une autre assembly appelée NewQuickSort.dll sans changer la référence dans Launcher.exe donc:

  • On supprime le code se trouvant dans QuickSort.dll.
  • On référence l’assembly NewQuickSort.dll dans QuickSort.dll et
  • On ajoute l’attribut TypeForwardedTo() dans QuickSort.dll pour transférer le type Example.QuickSort vers NewQuickSort.dll:
    using System.Runtime.CompilerServices;
    
    [assembly: TypeForwardedTo(typeof(Example.QuickSort))]
    
    namespace Example;
    

Le type Example.QuickSort se trouve désormais dans l’assembly NewQuickSort.dll. La référence typeof(Example.QuickSort) dans QuickSort.dll est possible car QuickSort.dll référence NewQuickSort.dll. Enfin l’assembly Launcher.exe n’est pas modifiée, elle référence uniquement QuickSort.dll et le code Example.QuickSort qui est censé s’y trouver.

A l’exécution, Launcher.exe instancie la classe Example.QuickSort à partir de la référence vers l’assembly QuickSort.dll. Le CLR charge le type directement dans l’assembly NewQuickSort.dll à cause du transfert de type de QuickSort.dll vers NewQuickSort.dll.

TypeForwardedFrom

On peut utiliser l’attribut System.Runtime.CompilerServices.TypeForwardedFromAttribute pour indiquer l’assembly à partir de laquelle un transfert de type est effectué. L’indication se fait en utilisant le nom complet de l’assembly comportant l’indication TypeForwardedTo. L’utilisation de l’attribut TypeForwardedFromAttribute est dans le but de documenter le transfert de type, il n’y a pas de traitement effectué par le CLR lorsque cet attribut est utilisé.

Dans notre exemple, on pourrait rajouter l’attribut TypeForwardedFromAttribute dans l’assembly NewQuickSort.dll pour indiquer que le transfert du type Example.QuickSort provient de l’assembly QuickSort.dll:

namespace Example;

[System.Runtime.CompilerServices.TypeForwardedFrom("QuickSort, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")]
public class QuickSort
{
  // ...
}

Différences de structure et de dépendances entre le framework .NET historique et .NET

Dans un 1er temps, on va indiquer quels sont les éléments constituant la structure d’une assembly .NET. Dans une 2e temps, on va chercher à trouver les différences de structure entre des assemblies ciblant des frameworks différents.

Structure d’une assembly

On peut se poser la question de savoir quelles sont les différences de structure des assemblies entre le framework .NET historique et .NET d’une part et les assemblies .NET ciblant Windows et ciblant Linux ou Mac OS d’autre part.

Le code .NET pouvant être exécuté est stocké dans des fichiers appelés assemblies. Ces assemblies sont composés des éléments suivants:
Les assemblies sont composées des éléments suivants:

  • PE Header: l’assembly est structurée dans un objet PE de plus bas niveau. A ce titre, il possède ce type d’en-tête.
  • Un manifest contenant une liste des références externes de l’assembly.
  • Sections contenant du code natif compilé.
  • Dans le cas d’assembly managée:
    • CLR Header: présent dans le cas d’une assembly managée. Ce sont des informations sur la version cible du framework .NET historique; éventuellement le hash pour la signature par nom fort (cf. Signature par nom fort); l’adresse dans le fichier des ressources et le point d’entrée indiquant la table des métadonnées permettant à l’assembly de s’autodécrire.
    • Liste des objets binaires utilisés dans les métadonnées.
    • Liste des chaines de caractères utilisées dans les métadonnées.
    • Liste des chaines de caractères utilisées dans le code IL (i.e. Intermediate Language).
    • Liste des GUID utilisés dans l’assembly.
    • Tables des métadonnées permettant d’indiquer des informations sur tous les types utilisés dans l’assembly.
    • Le code IL (i.e. Intermediate Language).

PE Header

Cet en-tête se trouve dans des fichiers dont le format est PE pour Portable Executable. Les fichiers contenant un en-tête PE Header sont des PE objects (i.e. objet PE). Ce format est commun pour structurer des fichiers différents comme des exécutables, des bibliothèques ou des drivers système. Il définit une structure connue du système d’exploitation pour qu’il puisse savoir où trouver des informations qui lui permettront de mapper le contenu du fichier organisé en sections à des zones en mémoire. Cette structure est indiquée dans l’en-tête du fichier (i.e. PE Header).

Le PE Header n’est pas spécifique à des assemblies .NET, il est aussi utilisé dans le cas de DLL natives.

Module

Un module est aussi un terme utilisé en managée et en non managée. Il désigne une unité de compilation contenant des métadonnées des types qu’il contient et du code compilé. Le code compilé n’est pas forcément du code machine. Il correspond à un niveau d’échelle plus élevé que les objets PE.

Un module ne peut pas être déployé seul et il ne contient pas de manifest c’est-à-dire qu’il n’indique pas quelles sont ses dépendances. Chaque module contient un seul type de code.

L’intérêt des modules est de pouvoir être utilisés indépendamment dans une unité déployable comme les assemblies.

Assembly

Une assembly est une unité déployable en opposition aux modules qui sont des unités de compilation. L’assembly peut ainsi être déployée seule. D’une façon générale, il s’agit de fichiers avec une extension .exe pour un exécutable ou .dll pour une bibliothèque de classes.

En .NET, les assemblies sont assez souples pour contenir un ou plusieurs modules, éventuellement des fichiers de ressources et des métadonnées. Les modules peuvent être compilés dans des langages différents. Il est aussi possible de merger le contenu de plusieurs assemblies dans une seule assembly.

On considère plusieurs types d’assemblies:

  • assembly managée: objet PE contenant du code managé. En .NET c’est la plus petite unité de déploiement. Dans la pratique, ces fichiers peuvent être des exécutables ou des bibliothèques de classes. Le plus souvent quand on utilise le terme assembly c’est pour désigner les assemblies managées.
  • assembly mixte: assembly .NET contenant à la fois du code managé et du code natif (cf. C++/CLI).
  • assembly native: on retrouve le terme assembly native dans la documentation Microsoft concernant WinSxS. Ce terme est ambigu car il laisse penser qu’il désigne d’assemblies .NET contenant seulement du code natif or, dans le cas de WinSxS, on parle bien de DLL natives classiques.En effet, en .NET, on distingue les assemblies (sous-entendu les assemblies managées) qui contiennent exclusivement du code managé et les assemblies mixtes contenant, à la fois du code managé et du code natif. Ainsi dans le cas où il n’y a que du code natif, le terme assembly native désigne un groupe d’une ou plusieurs DLL natives, de composant COM ou des collections de ressources, de types ou d’interfaces.
  • Side-by-side assembly: assembly native contenant une liste de ressources ou un groupe de DLL avec un manifest. Le loader du système d’exploitation utilise les informations du manifest pour identifier l’assembly et être capable de la charger quand un exécutable a une dépendance vers cette dernière. Elles ont une identité unique et sont utilisées pour éviter de casser des dépendances.
  • Private assembly: assembly native utilisée seulement par une seule application. Elle peut être inclue en tant que ressource d’une autre DLL ou installer dans le même répertoire que l’exécutable qui l’utilise.
  • Shared assembly: side-by-side assembly déployée dans le répertoire du cache des assemblies du système WinSxS. Ces assemblies peuvent être utilisées par un exécutable si la dépendance est indiquée dans son manifest.

Dans le cadre de cet article, par simplification, on ne s’intéressera qu’aux assemblies managées.

Code MSIL

En .NET, le code n’est pas directement compilé en code machine comme cela est le cas pour du code C++ natif. Le code .NET est compilé dans des assemblies contenant des instructions MSIL (pour MicroSoft Intermediate Language). Ces instructions sont exécutables par le CLR (i.e. Common Language Runtime).

Compilation avec Roslyn vs Compilation avec le JIT

A l’exécution et suivant les besoins du CLR, les instructions MSIL sont de nouveau compilées en code machine par le compilateur JIT (i.e. Just In Time). Le code machine généré est ensuite exécuté par la machine. Les instructions MSIL sont compilées à la demande, en fonction des appels qui sont effectués. Si des instructions correspondant à une fonction ne sont pas appelées alors ces instructions ne seront pas compilées par le compilateur JIT. D’autre part, le compilateur JIT effectue des optimisations dans le code généré suivant la façon dont les fonctions sont appelées. Ainsi les performances d’exécution d’un programmation peuvent s’améliorer au fur et à mesure de son exécution.

On peut trouver le terme CIL (pour Common Intermediate Language) pour désigner du code IL. Il correspond aux mêmes jeux d’instructions que le MSIL toutefois ce terme est utilisé dans le cadre du standard CLI (i.e. Common Language Infrastructure).

Comparaison entre des assemblies ciblant différents frameworks

On va considérer du code .NET simple que l’on va stocker dans une bibliothèque de classes (i.e. class library) nommée QuickSort.dll et on va cibler le framework .NET historique 4.8, .NET 6.0 sous Windows et sous Linux.

Le code correspondant à une classe dont la structure est (le code précis n’a pas d’importance mais on peut le consulter sur GitHub src/QuickSort/QuickSort/QuickSort.cs):

namespace Example
{ 
  public class QuickSort
  {
    private readonly int[] originalNumbers;
    public QuickSort(int numberCount)
    {
      [...]
    }

    public IReadOnlyList<int> OriginalNumbers => this.originalNumbers;
    public IReadOnlyList<int> Sort()
    {
       [...]
    }
    private static void Quicksort(int[] numbers, int first, int last)
    {
      [...]
    }
  }
}
Comment générer dans Visual une assembly en ciblant le framework .NET historique 4.8 ?

Il faut éditer le fichier projet en faisant un clique droit sur le projet puis “Edit Project File” et modifier le contenu de cette façon:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net4.8</TargetFramework>   
    <ImplicitUsings>disable</ImplicitUsings>
    <Nullable>disable</Nullable>
  </PropertyGroup>
</Project>
    
  1. Enregistrer le fichier .csproj.
  2. Faire un clique droit sur le projet puis Publish.
  3. Sélectionner les valeurs suivantes:
    • Configuration: Release | Any CPU
    • Target Framework: net4.8
    • Target runtime: Portable
    • Target location: bin\Release\net4.8\publish\win-x64\
  4. Cliquer sur Publish

Comment générer dans Visual une assembly avec pour runtime cible linux ?

Le contenu du fichier .csproj doit permettre de cibler .NET 6.0:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>disable</ImplicitUsings>
    <Nullable>disable</Nullable>
  </PropertyGroup>
</Project>
      
  1. Puis faire un clique droit sur le projet puis Publish.
  2. Sélectionner les valeurs suivantes:
    • Configuration: Release | Any CPU
    • Target Framework: net6.0
    • Deployment mode: Portable
    • Target runtime: linux-x64
    • Target location: bin\Release\net6.0\publish\linux-x64\
  3. Cliquer sur Publish

Refaire la même opération avec Target runtime: win-x64 et Target location: bin\Release\net6.0\publish\win-x64\.

Les assemblies générées devraient se trouver dans les répertoires:

  • Runtime ciblant framework 4.8 pour Windows: bin\Release\net4.8\publish\win-x64\
  • Runtime ciblant .NET 6.0 pour Windows: bin\Release\net6.0\publish\win-x64\
  • Runtime ciblant .NET 6.0 pour Linux: bin\Release\net6.0\publish\linux-x64\

Si on compare les assemblies entre elles, on peut voir qu’elles ne sont pas tout à fait identiques:

  • Entre les assemblies .NET 6.0 ciblant Windows ou Linux:
    fc /b bin\Release\net6.0\publish\linux-x64\QuickSort.dll bin\Release\net6.0\publish\win-x64\QuickSort.dll
    Comparing files BIN\RELEASE\NET6.0\PUBLISH\LINUX-X64\QuickSort.dll and BIN\RELEASE\NET6.0\PUBLISH\WIN-X64\QUICKSORT.DLL
    00000088: 4F AF
    00000089: 7B 74
    0000008A: 7D 07
    0000008B: B7 8F
    ...
    
  • Entre les assemblies .NET 6.0 ciblant Windows et l’assembly ciblant le framework .NET historique 4.8, les différences sont plus importantes:
    fc /b bin\Release\net4.8\publish\win-x64\QuickSort.dll bin\Release\net6.0\publish\win-x64\QuickSort.dll
    Comparing files BIN\RELEASE\NET4.8\PUBLISH\WIN-X64\QuickSort.dll and BIN\RELEASE\NET6.0\PUBLISH\WIN-X64\QUICKSORT.DLL
    00000084: 4C 64
    00000085: 01 86
    00000086: 03 02
    00000088: 08 AF
    00000089: 67 74
    0000008A: FE 07
    0000008B: BC 8F
    [...]
    FC: BIN\RELEASE\NET4.8\PUBLISH\WIN-X64\QuickSort.dll longer than BIN\RELEASE\NET6.0\PUBLISH\WIN-X64\QUICKSORT.DLL
    

Dans un 1er temps, on peut comparer les headers de ces fichiers avec dumpbin. Cet utilitaire permet d’afficher le PE Header de DLL. On peut obtenir cet outil en installant les “C++ profiling tools” dans la rubrique “Desktop development with C++” de l’installateur de Visual Studio. Après installation, cet outil se trouve dans un répertoire du type:
C:\Program Files\Microsoft Visual Studio\<Version VS>\Community\VC\Tools\MSVC\<Version compilateur>\bin\Hostx64\x64\dumpbin.exe.

Par exemple:

C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.34.31933\bin\Hostx64\x64\dumpbin.exe

Ainsi, si on extrait le header pour chaque DLL en exécutant à partir de l’invite de commande “Native tools Command prompt”:

dumpbin /headers <chemin de l'assembly>

En comparant les headers des différentes versions de l’assembly QuickSort.dll, on peut voir que:

  • Il n’y a pas de différences de headers entre les assemblies ciblant .NET 6.0 pour Windows ou pour Linux et
  • La seule différence notable dans le header entre une assembly ciblant .NET 6.0 et une autre assembly ciblant le framework .NET historique est dans la donnée "subsystem version":
    Extrait du header de l’assembly ciblant le framework .NET historique: Extrait du header de l’assembly ciblant .NET 6.0:
    OPTIONAL HEADER VALUES
          20B magic # (PE32+)
          48.00 linker version
          A00 size of code
          400 size of initialized data
            0 size of uninitialized data
            0 entry point
         2000 base of code
        180000000 image base (0000000180000000 to 0000000180005FFF)
         2000 section alignment
          200 file alignment
         4.00 operating system version
         0.00 image version
         6.00 subsystem version
    [...]
    OPTIONAL HEADER VALUES
          20B magic # (PE32+)
          48.00 linker version
          A00 size of code
          400 size of initialized data
            0 size of uninitialized data
            0 entry point
         2000 base of code
        180000000 image base (0000000180000000 to 0000000180005FFF)
         2000 section alignment
          200 file alignment
         4.00 operating system version
         0.00 image version
         4.00 subsystem version
    [...]

Les différences se trouvent donc ailleurs.

On se propose, ensuite, de comparer les metainfos des assemblies en utilisant ildasm (cet outil se trouve dans un chemin de type: C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8.1 Tools). Pour effectuer cette comparaison, on ouvre ildasm pour chaque assembly et on affiche les metainfos en allant dans ViewMetainfoShow (il faut que dans le menu metainfo aucune entrée ne soit cochée).

En comparant les metainfos des différentes versions de QuickSort.dll, on peut indiquer:

  • Il n’y a pas de différences entre les assemblies ciblant .NET 6.0 pour Windows ou pour Linux et
  • Entre des assemblies ciblant le framework .NET historique et NET 6.0, on peut citer quelques différences:
    • La référence vers l’assembly principale spécifique à chaque framework:
      • System.Runtime pour .NET 6.0
      • mscorlib pour le framework .NET historique
    • La présence d’attributs générés par le compilateur dans l’assembly ciblant .NET:
      • System.Runtime.CompilerServices.RefSafetyRulesAttribute: permettant d’indiquer que le module a été compilé en respectant les règles de sécurité sur les ref C# 11.
      • System.Runtime.CompilerServices.CompilerGeneratedAttribute: permettant de distinguer un objet généré par le compilateur d’un objet généré par l’utilisateur.
    • La valeur de l’attribut System.Runtime.Versioning.TargetFrameworkAttribute:
      • .NETCoreApp,Version=v6.0 ou
      • .NETFramework,Version=v4.8

Ces différences peuvent se voir aussi directement en comparant le manifest entre les 2 assemblies:

Extrait du manifest de l’assembly ciblant le framework .NET historique: Extrait du manifest de l’assembly ciblant .NET 6.0:
// Metadata version: v4.0.30319
.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) 
  .ver 4:0:0:0
}
.assembly QuickSort
{
  // [...]
}
.module QuickSort.dll
// MVID: {F3745038-C191-4DBF-BEBB-3621DFC9BE88}
.imagebase 0x0000000180000000
.file alignment 0x00000200
.stackreserve 0x0000000000400000
.subsystem 0x0003     // WINDOWS_CUI
.corflags 0x00000001  //  ILONLY
// Image base: 0x06950000
// Metadata version: v4.0.30319
.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) 
  .ver 7:0:0:0
}
.assembly QuickSort
{
  // [...]
}
.module QuickSort.dll
// MVID: {65CC543B-740E-4A65-A06C-529083C52F97}
.custom instance void 
  System.Runtime.CompilerServices.RefSafetyRulesAttribute::.ctor(int32) = 
  ( 01 00 0B 00 00 00 00 00 ) 
.imagebase 0x0000000180000000
.file alignment 0x00000200
.stackreserve 0x0000000000400000
.subsystem 0x0003     // WINDOWS_CUI
.corflags 0x00000001  //  ILONLY
// Image base: 0x08DF0000

Equivalences entre le framework .NET historique et .NET

Comme on a pu le voir précédemment, le fonctionnement général de .NET est similaire entre .NET et le framework .NET historique. Les différences les plus importantes sont dans l’amorce de l’exécutable, les répertoires parcourus pour charger les dépendances et les dépendances elles-mêmes. De ces 3 éléments, les dépendances vont fortement contribuer à rendre plus difficile la compatibilité entre .NET et le framework .NET historique. Il existe des solutions pour limiter les incompatibilités entre les 2 implémentations de façon à porter des implémentations d’un framework à l’autre en gardant en tête que le portage le plus utile est du framework .NET historique vers .NET. L’autre sens n’a pas forcément un intérêt mais on étudiera quand même cette possibilité.

La solution la plus triviale pour rendre du code prévu pour le framework .NET historique compatible avec .NET est d’utiliser .NET Standard. Toutefois dans les cas où on ne peut pas refactorer ou recompiler des assemblies, il existe d’autres solutions.

.NET Standard

.NET Standard est l’approche historique de Microsoft pour trouver une compatibilité entre le framework .NET historique et les nouveaux frameworks .NET Core/.NET. L’approche a été d’inclure des types et objets dans le standard, chaque framework implémente le standard. Ainsi lorsqu’on construit une application ou une assembly, on ne dépend plus d’un framework mais du standard. La construction de .NET Standard permet ensuite de garantir que si une assembly dépend du standard alors elle est compatible avec les frameworks implémentant ce standard (pour davantage de détails sur la construction de .NET Standard, voir Comprendre .NET Standard en 5 min). La 1ère version de .NET Standard est la version 1.0, elle comprend peu d’objets mais beaucoup de frameworks l’implémentent.

D’une façon générale, plus on incrémente les versions de .NET Standard et plus le standard prend en compte des types et des objets (voir le tableau sur dotnet.microsoft.com/en-us/platform/dotnet-standard#versions):

Comparaison entre .NET Standard 1.0 et .NET Standard 2.1

Ainsi:

  • Plus on incrémente les versions de .NET Standard et moins de framework implémente le standard: cela s’explique par le fait que les frameworks les plus obsolètes n’évoluent plus.
  • La dernière version du standard c’est-à-dire la 2.1 n’est pas prise en charge par le framework .NET historique. Le framework .NET 4.8.x implémente au maximum .NET Standard 2.0.

Lorsqu’on développe une application ou des assemblies, il est tentant de les faire dépendre de .NET Standard plutôt qu’un framework particulier. Il faut, toutefois, avoir en tête que l’approche .NET Standard est abandonnée et ce standard ne sera plus incrémenté(*). Ainsi la version 8.0 de .NET qui est la dernière version de ce framework ne prend en charge aucune version de .NET Standard. La dernière version de .NET implémentant .NET Standard est la version 7.0. .NET Standard convient bien pour les anciennes applications, en particulier celles utilisant le framework .NET historique car cela permet de procéder par étape si on souhaite porter ces applications sur .NET. Toutefois dans le cadre de nouvelles applications, il vaut mieux utiliser directement .NET.

En effet, actuellement la très grande majorité des fonctionnalités du framework .NET historique existe en .NET y compris les fonctionnalités spécifiques aux plateformes Windows (WPF, WinForms, C++/CLI etc…). D’autre part, lorsqu’une fonctionnalité dans le framework .NET historique n’existe pas en .NET, il existe des équivalents. Il n’y a donc plus de raisons de chercher à garder une compatibilité avec la framework .NET historique.

Exemple d’utilisation de .NET Standard

A titre d’exemple, on se propose d’utiliser .NET Standard comme framework cible pour des assemblies de type bibliothèque de classes (i.e. class library). Dans notre exemple, on considère 2 assemblies:

  • QuickSort.dll: bibliothèque de classes comportant la classe QuickSort (l’implémentation de cette classe n’a pas d’importance):
    namespace Example
    {
      public class QuickSort
      {
      // ...
      }
    }
    
  • Launcher.exe: exécutable consommant l’assembly QuickSort.dll:
    namespace Example.Launcher
    {
      class TestClass
      {
        static void Main(string[] args)
        {
          var quickSort = new QuickSort(10);
          Console.WriteLine($"Original numbers: {string.Join("; ", quickSort.OriginalNumbers)}");
          var sortedNumbers = quickSort.Sort();
          Console.WriteLine($"Sorted numbers: {string.Join("; ", sortedNumbers)}");
        }
      }
    }
    

Ces 2 assemblies ciblent .NET 6.0. Si on regarde la propriété TargetFramework dans les .csproj:

  • QuickSort.dll:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Library</OutputType>
        <TargetFramework>net6.0</TargetFramework> 
      </PropertyGroup>
    </Project>
    
  • Launch.exe:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\QuickSort\QuickSort.csproj" />
      </ItemGroup>
    </Project>
    

On souhaite modifier le framework cible de QuickSort.dll pour cibler .NET Standard plutôt qu’un framework précis. Si on regarde le tableau de compatibilité sur learn.microsoft.com/en-us/dotnet/standard/net-standard, on peut voir que .NET 6.0 implémente toutes les versions de .NET Standard:

.NET 6.0 implémente toutes les versions de .NET Standard

De façon à être compatible avec le framework .NET historique, on va cibler .NET Standard 2.0 (le framework .NET historique n’implémente pas .NET Standard 2.1).

Pour changer le framework cible dans Visual Studio:

  1. Clique droit sur le projet puis Properties
  2. Dans la partie ApplicationGeneralTarget Framework

    Comme on peut le voir, on ne voit pas .NET Standard. Cela s’explique par le fait que .NET Standard est abandonné et Visual Studio n’expose pas directement la possibilité de sélectionner .NET Standard. On peut toutefois le faire en faisant:

    • Un clique droit sur le projet puis Edit project file.
    • Il faut modifier le fichier .csproj en remplaçant net6.0 par netstandard2.0 pour le paramètre TargetFramework:
      <Project Sdk="Microsoft.NET.Sdk">
        <PropertyGroup>
          <OutputType>Library</OutputType>
          <TargetFramework>netstandard2.0</TargetFramework>   
          <Nullable>disable</Nullable>
        </PropertyGroup>
      </Project>
      

      netstandard2.0 est un moniker du framework à cibler pour .NET Standard 2.0. On peut voir d’autres exemples de monikers sur learn.microsoft.com/en-us/dotnet/standard/frameworks.

Si on compile le projet QuickSort en l’état, on peut obtenir des erreurs dues à certaines fonctionnalités n’étant pas compatibles avec certaines versions de C#, par exemple:

Feature 'global using directive' is not available in C# 7.3. Please use language version 10.0 or greater.

On peut résoudre ce problème en désactivant les fonctionnalités incompatibles en rajoutant dans le .csproj:

<ImplicitUsings>disable</ImplicitUsings>

La compilation réussit puisque Launcher.exe cible .NET 6.0 qui est compatible avec .NET Standard 2.0.
Si on change le framework cible de Launcher.exe en choisissant le framework .NET historique (moniker net48):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\QuickSort\QuickSort.csproj" />
  </ItemGroup>
</Project>

La compilation réussit toujours puisque le framework .NET 4.8 est compatible avec .NET Standard 2.0.

Construction de .NET Standard

Pour comprendre la façon dont .NET Standard est construit, voir Comprendre .NET Standard en 5 min.

Comme on peut le voir, compiler du code historique pour cibler .NET Standard nécessite de modifier les fichiers .csproj et de chercher un compromis dans le choix de la bonne version de .NET Standard. Ces étapes peuvent être couteuses en refactoring dans des projets de grande taille.

Compatibility shim

Utiliser .NET Standard n’est pas la seule façon d’utiliser des assemblies ne ciblant pas le même framework. Les compatibility shims ont régulièrement été utilisés par Microsoft pour assurer une compatibilité ascendante de ses frameworks:

  • Entre la version 2.0 et 4.0 du framework .NET historique,
  • Entre le framework .NET historique et .NET Core 2.0 avec Windows Compatibility Pack,
  • Entre le framework .NET historique 4.8 et .NET.

On va s’intéresser spécifiquement à ce dernier cas. Le but étant de pouvoir utiliser une assembly ciblant le framework .NET historique dans une application .NET sans recompilation et sans modifier le framework cible. Dans ce cas, la compatibility shim utilisée consiste à effectuer des redirections de types (i.e. Type forwarding). On va détailler cette approche par la suite.

De façon à expliciter l’approche des compatibility shims, on se propose de croiser l’utilisation d’assemblies ne ciblant pas les mêmes frameworks:

  • En consommant une assembly ciblant le framework .NET historique dans une application .NET et
  • En consommant une assembly ciblant .NET dans une application ciblant le framework .NET historique.

Utiliser des assemblies framework .NET dans une application ciblant .NET

Cette approche consiste à vérifier s’il est possible de charger une dépendance ciblant le framework .NET historique à partir d’une application .NET. Comme on a pu le voir précédemment, le fonctionnement du CLR entre le framework .NET historique et .NET est très similaire, les grandes différences résident dans les chemins utilisés pour la recherche des dépendances. D’autre part, on a pu observer qu’il n’y a pas de différences dans la structure des assemblies lorsqu’elles ciblent des frameworks différents. La différence réside essentiellement dans les dépendances requises.

C’est essentiellement ce dernier point que Microsoft a tenté de résoudre pour permettre de charger des assemblies ciblant le framework .NET historique dans une application .NET. La compatibility shim utilisée consiste à effectuer une redirection des types (i.e. Type forwarding) du framework historique vers les mêmes types sous .NET.

Si on reprend l’exemple précédent avec les assemblies Launcher.exe et QuickSort.dll . On compile l’assembly QuickSort.dll pour cibler le framework .NET historique:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net48</TargetFramework>
    <ImplicitUsings>disable</ImplicitUsings>
    <Nullable>disable</Nullable>
    <Platforms>AnyCPU;x64</Platforms>
  </PropertyGroup>
</Project>

Si on regarde le manifest de l’assembly avec ildasm.exe (accessible sur C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8.1 Tools), on peut voir:

// Metadata version: v4.0.30319
.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )             
  .ver 4:0:0:0
}
.assembly QuickSort
{
  .custom instance void [mscorlib]System.Runtime.CompilerServices
    .CompilationRelaxationsAttribute::.ctor(int32) = ( 01 00 08 00 00 00 00 00 ) 
  .custom instance void [mscorlib]System.Runtime.CompilerServices
    .RuntimeCompatibilityAttribute::.ctor() = ( 01 00 01 00 54 02 16 57 72 61 70 4E 6F 6E 45 78   
                                                       63 65 70 74 69 6F 6E 54 68 72 6F 77 73 01 )     

   // ...
  .hash algorithm 0x00008004
  .ver 1:0:0:0
}

L’assembly possède une dépendance vers mscorlib.dll qui est l’assembly de base de framework .NET historique.

Ensuite on compile l’assembly Launcher.exe en mode “self-containded”:

  1. Faire un clique droit sur le projet Launcher puis cliquer sur Publish
  2. Cliquer sur Show all settings puis indiquer les paramètres:
    • Configuration: Release | Any CPU
    • Target Framework: net6.0
    • Deployment mode: Self-contained
    • Target runtime: win-x64
  3. Cliquer sur Save puis Publish

Dans le répertoire de sortie, on peut constater qu’il contient toutes les dépendances. Si on ouvre l’assembly mscorlib.dll avec ildasm, on constate dans le manifest:

// Metadata version: v4.0.30319
.assembly extern System.Private.CoreLib
{
  .publickeytoken = (7C EC 85 D7 BE A7 79 8E )             
  .ver 0:0:0:0
}
// ...
.class extern forwarder System.Object
{
  .assembly extern System.Private.CoreLib
}
// ...

L’assembly mscorlib.dll n’est pas une vraie assembly. Il n’y a pas d’implémentation concernant les objets. Ainsi, l’objet System.Object ne possède pas d’implémentation dans cette assembly. En revanche, elle contient des redirections de types. L’objet System.Object est redirigé vers l’assembly System.Private.CoreLib. Cette assembly fait partie de .NET et contient des types de base du framework.

Si on exécute l’application, on constate qu’elle s’exécute normalement. La dépendance QuickSort.dll est bien chargée malgré le fait qu’elle cible le framework .NET historique. Les redirections de type permettent de substituer les types du framework .NET historique vers les types correspondant dans le .NET.

Ainsi le gros avantage de cette approche est qu’elle ne nécessite pas de recompilation ni de changement de framework cible (comme pour l’approche .NET Standard). Il faut, toutefois, avoir en tête que cette compatibility shim n’est pas infaillible: il peut subsister des différences entre une fonction appelée dans le framework .NET historique et la fonction se trouvant dans l’assembly vers laquelle les redirections sont effectuées. La moindre différence de signature entraînera une exception à l’exécution. D’autre part, plus .NET évolue et plus il s’écarte du framework .NET historique rendant les probabilités d’incompatibilités de plus en plus importantes.

Cette approche est donc plus risquée que .NET Standard puisque les problèmes d’incompatibilités ne peuvent être constatés qu’à l’exécution et non durant la compilation. Il faut donc bien tester l’exécution du code en essayant de balayer tous les scénarios d’appels de fonction utilisant une redirection de type.

Utiliser des assemblies .NET dans une application framework .NET

Cette approche consiste à effectuer l’inverse de ce que nous avons expérimenté précédemment. Nous cherchons à exécuter une application ciblant le framework .NET historique et à charger une assembly ciblant .NET.

Ainsi de façon à ne pas avoir d’erreurs de compilation, on compile Launcher.exe et QuickSort.dll en ciblant le framework .NET historique:

  1. On modifie le fichier .csproj de Launcher.exe de cette façon:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <Platforms>AnyCPU;x64</Platforms>
        <TargetFramework>net48</TargetFramework>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\QuickSort\QuickSort.csproj" />
      </ItemGroup>
    </Project>
    
  2. De même pour QuickSort.dll:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Library</OutputType>
        <TargetFramework>net6.0</TargetFramework> 
        <ImplicitUsings>disable</ImplicitUsings>
        <Nullable>disable</Nullable>
        <Platforms>AnyCPU;x64</Platforms>
      </PropertyGroup>
    </Project>
    
  3. L’application devrait compiler normalement et le répertoire de sortie devrait contenir les fichiers:
    • Launcher.exe
    • Launcher.exe.config
    • Launcher.pdb
    • QuickSort.dll
    • QuichSort.pdb
  4. On publie QuickSort.dll pour cibler .NET en mode “self-contained” de façon à ce que le répertoire de sortie contienne toutes les dépendances .NET. On effectue un clique droit sur le projet QuickSort puis on clique sur Publish
  5. Cliquer sur Show all settings puis indiquer les paramètres:
    • Configuration: Release | Any CPU
    • Target Framework: net6.0
    • Deployment mode: Self-contained
    • Target runtime: win-x64
  6. Cliquer sur Save puis Publish

    Dans le répertoire de sortie, on peut constater qu’il contient toutes les dépendances.

  7. Copier toutes les dépendances du répertoire de sortie dans le répertoire de sortie de Launcher.exe.

Si on exécute l’application, on obtient l’erreur suivante:

Unhandled Exception: System.TypeLoadException: Could not load type 'System.Object' from assembly 'System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e' because the parent does not exist.

Ceci s’explique par le chargement du type System.Object à partir de 2 emplacements différents.
Si on regarde le code IL du constructeur .ctor : void () de la classe Example.Launcher.TestClass de l’assembly Launcher.exe avec ildasm.exe:

.method public hidebysig specialname rtspecialname 
    instance void  .ctor() cil managed
{
  // Code size     7 (0x7)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  call     instance void [mscorlib]System.Object::.ctor()
  IL_0006:  ret
} // end of method TestClass::.ctor

On peut voir que le type System.Object est chargé à partir de l’assembly mscorlib.dll (appartenant au framework .NET historique).

Si on regarde le code IL du constructeur .ctor : void() de la classe Example.QuickSort de l’assembly QuickSort.dll avec ildasm.exe:

.method public hidebysig specialname rtspecialname 
    instance void  .ctor(int32 numberCount) cil managed
{
  // Code size     51 (0x33)
  .maxstack  3
  .locals init (class [System.Runtime]System.Random V_0,
       int32 V_1)
  IL_0000:  ldarg.0
  IL_0001:  call     instance void [System.Runtime]System.Object::.ctor()
  IL_0006:  ldarg.0
  // ...
} // end of method QuickSort::.ctor

On constate que le type System.Object est chargé à partir de l’assembly System.Runtime (appartenant au framework .NET).

Ainsi au démarrage de l’application, le CLR charge le type System.Object à partir de mscorlib.dll. Plus tard dans l’exécution de l’application pour le constructeur de la classe Example.QuickSort, il tente d’utiliser le type System.Object à partir de System.Runtime, or si ce type est déjà chargé toutefois il provient de 2 assemblies différentes d’où l’erreur à l’exécution.

Dans le message d’erreur "Could not load type 'System.Object' from assembly 'System.Private.CoreLib [...]' because the parent dœs not exist.", on peut se demander ce que signifie le terme “parent” ? Le code du CLR permet de comprendre ce terme: dans la fonction QuickSort.dllClassLoader::LoadApproxParentThrowing (le message de l’erreur est IDS_CLASSLOAD_PARENTNULL), on comprend que “parent” désigne le parent de tous les objets en .NET c’est-à-dire System.Object. Lors de l’exécution de ce code, le CLR part du principe que tous les objets en .NET ont pour parent System.Object dans mscorlib.dll or l’objet System.Object dans System.Private.CoreLib ne possède pas pour parent System.Object dans mscorlib.dll. La recherche du parent échoue pour System.Object dans System.Private.CoreLib d’où l’erreur.

Par suite l’objet System.Object dans System.Private.CoreLib ne peut être chargé. Ce problème ne permet pas une exécution de cette façon, ce qui rend incompatible les assemblies .NET avec les applications du framework .NET historique. Cela peut se comprendre puisque ce use-case n’a pas un réel intérêt fonctionnel (compatibilité descendante). Microsoft n’a donc pas cherché à le résoudre.

Conclusion

Pour une application, lorsque le volume de code existant est important, il est compliqué de savoir quelle est la meilleure approche pour faire évoluer les différentes dépendances et éviter une trop grande dette technique. Bien-que source d’instabilité, dans le milieu des années 2010, il aurait été inconcevable pour Microsoft de continuer à faire évoluer .NET sans proposer une réelle rupture en ouvrant cette technologie à d’autres plateformes que Windows. Problématique pour beaucoup d’équipes de développeurs, cette rupture tend à se stabiliser. En effet, actuellement Microsoft, a terminé sa migration du framework .NET historique vers .NET:

  • Le framework .NET historique n’évolue, désormais, plus fonctionnellement,
  • .NET Standard qui était l’approche de transition entre le framework .NET historique et .NET, est abandonné,
  • Le renommage de .NET Core en .NET qui était l’appellation initiale du framework .NET historique achève de tourner la page du framework historique.

Même si la période de transition entre l’arrêt du framework .NET historique et la poursuite des nouvelles fonctionnalités exclusivement sur .NET est maintenant terminée, elle aura duré 4 ans entre la 1ère version de .NET Core en juin 2016 et l’annonce de l’abandon de .NET Standard en septembre 2020. Durant 4 ans, pour tous les développeurs .NET s’est posé la douloureuse question de savoir comment faire évoluer son application:

  • Continuer à développer en ciblant le framework .NET historique en 4.8,
  • Migrer en ciblant .NET Standard,
  • Migrer en ciblant .NET Core puis .NET.

Pour encore beaucoup d’applications, la masse de code peut rendre cette évolution compliquée et couteuse. Dans ce optique, cet article avait pour but de montrer que l’approche .NET n’est pas complètement différente de l’approche du framework .NET historique:

  • Les assemblies ont la même structure entre les différents frameworks,
  • Suivant le framework ciblé, la plus grande différence dans les assemblies réside dans les dépendances,
  • Le CLR se comporte de la même façon pour charger des dépendances même si les répertoires de recherche des dépendances ne sont pas les mêmes.
  • Certaines fonctionnalités comme les AppDomains sont différentes entre le framework .NET historique et .NET.
  • Des compatibility shims permettent de continuer à utiliser des assemblies ciblant le framework .NET historique dans des applications .NET au prix d’effectuer des tests à l’exécution pour minimiser le risque d’exception en cas d’incompatibilité.
  • Ces compatibility shims permettent d’éviter de devoir recompiler ou changer la cible d’assemblies historiques.

J’espère que cet article vous aura aider à appréhender plus facilement le passage du framework .NET historique vers .NET.

Références

(*)Abandon .NET Standard:

Changement des assemblies:

App host:

AssemblyLoadContext

Affichage des dépendances

Type forwarding:

Assembly structure:

Autres:

Leave a Reply