Wszystkie wpisy

Architektura pluginów w C# - Assembly, dynamiczne ładowanie DLL, AssemblyLoadContext

Praktyczne zastosowanie refleksji - aplikacja rozszerzalna o pluginy ładowane z plików DLL w trakcie wykonania. Assembly, Assembly.LoadFrom, AssemblyLoadContext, izolacja wersji oraz pułapki produkcyjne. Dziewiąta część kursu C#.


W poprzednim wpisie Refleksja i atrybuty w C# zobaczyłeś, jak program może w trakcie wykonania czytać metadane o swoich własnych typach. Teraz wykorzystamy tę zdolność do czegoś znacznie potężniejszego: wczytania nowego kodu z pliku .dll, który Twoja aplikacja widzi pierwszy raz w życiu. To architektura pluginów - filar narzędzi rozszerzalnych takich jak Visual Studio, Unity, Rider czy ASP.NET Core.

W tej części poznasz klasę Assembly, sposób ładowania DLL-i z dysku, mechanizm odnajdowania klas implementujących Twój interfejs oraz AssemblyLoadContext - profesjonalne narzędzie do izolacji wersji bibliotek między pluginami.

1. Czym jest Assembly #

Co to jest: assembly to podstawowa jednostka wdrożenia kodu w .NET - skompilowany plik .dll lub .exe zawierający kod IL, metadane (klasy, metody, atrybuty) oraz zasoby. To “paczka” z kodem, którą wczytujesz do procesu.

Dlaczego istnieje: kompilator C# nie tworzy pojedynczych plików z każdą klasą. Zamiast tego pakuje powiązane typy w jedną binarną jednostkę - assembly. Każdy projekt = jedno assembly = jeden plik .dll/.exe. Z perspektywy systemu uruchomieniowego (CLR) ładujesz, izolujesz, wersjonujesz i wykonujesz całe assembly, nie poszczególne klasy.

Klasa System.Reflection.Assembly reprezentuje to programowo. Trzy najczęstsze sposoby zdobycia jej obiektu:

// 1. Bieżące assembly (to, w którym ten kod jest skompilowany)
Assembly current = Assembly.GetExecutingAssembly();

// 2. Wczytanie pliku z dysku
Assembly loaded = Assembly.LoadFrom(@"C:\Plugins\WeatherPlugin.dll");

// 3. Po nazwie (jeśli już załadowane)
Assembly named = Assembly.Load("MyLibrary");

Każdy Assembly daje dostęp do swoich typów przez GetTypes():

foreach (Type type in assembly.GetTypes())
{
    Console.WriteLine(type.FullName);
}

To wszystko, czego potrzebujesz, by zbudować mechanizm pluginów.

2. Wzorzec plugin - kontrakt + implementacje #

Architektura pluginów opiera się na trzech projektach o ścisłych odpowiedzialnościach:

PluginBase.dll       ← wspólny kontrakt (interfejs IPlugin)
       ↑                    ↑                    ↑
       │                    │                    │
   AppHost.exe       WeatherPlugin.dll      NewsPlugin.dll
   (host)              (plugin)              (plugin)

Krok 1: wspólny kontrakt #

Host i pluginy muszą mówić tym samym językiem. Tym językiem jest interfejs zdefiniowany w trzecim projekcie - bibliotece klas, do której referują się i host, i każdy plugin.

// PluginBase/IPlugin.cs (osobny projekt: dotnet new classlib -o PluginBase)
namespace PluginBase;

public interface IPlugin
{
    string Name { get; }
    void Run();
}

Krok 2: konkretny plugin #

Inny programista (lub Ty, w innym projekcie) tworzy projekt klasy, dodaje referencję do PluginBase i implementuje interfejs:

// WeatherPlugin/WeatherPlugin.cs (osobny projekt: dotnet new classlib -o WeatherPlugin)
using PluginBase;

namespace WeatherPlugin;

public class WeatherPlugin : IPlugin
{
    public string Name => "Weather Plugin v1.0";

    public void Run()
    {
        Console.WriteLine("[Weather Plugin] Connecting to radar... Will rain today!");
    }
}

Kompilacja produkuje WeatherPlugin.dll. Plugin nie wie nic o aplikacji-hoście. Zna tylko interfejs IPlugin z PluginBase.dll.

Krok 3: host ładuje plugin #

Host w trakcie startu (lub na żądanie) skanuje folder z DLL-ami, ładuje każdy, wyszukuje implementacje IPlugin i tworzy ich instancje.

using System.Reflection;
using PluginBase;

public class PluginManager
{
    public void LoadAndRunPlugins(string directoryPath)
    {
        // 1. Znajdź wszystkie DLL-e w folderze
        string[] dllFiles = Directory.GetFiles(directoryPath, "*.dll");

        foreach (string file in dllFiles)
        {
            Console.WriteLine($"Found library: {file}");

            // 2. Wczytaj assembly do pamięci procesu
            Assembly assembly = Assembly.LoadFrom(file);

            // 3. Przejdź po wszystkich typach w tym assembly
            foreach (Type type in assembly.GetTypes())
            {
                // 4. Filtruj - implementuje IPlugin, nie jest interfejsem ani klasą abstrakcyjną
                if (typeof(IPlugin).IsAssignableFrom(type)
                    && !type.IsInterface
                    && !type.IsAbstract)
                {
                    Console.WriteLine($"Found plugin in class: {type.Name}");

                    // 5. Dynamiczne utworzenie instancji
                    IPlugin plugin = (IPlugin)Activator.CreateInstance(type);

                    // 6. Wywołanie metod kontraktu
                    Console.WriteLine($"Running: {plugin.Name}");
                    plugin.Run();
                }
            }
        }
    }
}

Cała magia kryje się w kombinacji refleksji (GetTypes, IsAssignableFrom) z dynamicznym tworzeniem obiektu (Activator.CreateInstance). Host nie ma w kodzie żadnego new WeatherPlugin() - nigdy nie widział tej klasy. Mimo to potrafi ją odnaleźć i uruchomić.

Dlaczego filtr !IsInterface && !IsAbstract #

Sam interfejs IPlugin formalnie spełnia warunek typeof(IPlugin).IsAssignableFrom(t) - interfejs implementuje sam siebie. To samo dotyczy abstrakcyjnej klasy bazowej, jeśli ktoś takiej użyje. Próba Activator.CreateInstance na interfejsie albo klasie abstrakcyjnej kończy się wyjątkiem MissingMethodException - nie ma czego instancjonować. Filtr trzeba dołożyć.

3. AssemblyLoadContext - profesjonalna izolacja #

Assembly.LoadFrom jest proste, ale ma ograniczenia produkcyjne. Wyobraź sobie scenariusz:

  • Plugin A wymaga Newtonsoft.Json 12.0
  • Plugin B wymaga Newtonsoft.Json 13.0
  • Host już ma załadowany Newtonsoft.Json 11.0

Assembly.LoadFrom ładuje wszystko do tego samego kontekstu - pierwsza załadowana wersja “wygrywa”. Pluginy A i B dostają tę samą bibliotekę, której nie zamawiały - albo wybuchają, bo zmieniło się API między wersjami.

Rozwiązaniem jest AssemblyLoadContext wprowadzony w .NET Core 3.0. Cytat z dokumentacji Microsoftu:

AssemblyLoadContext is a special type in the runtime that allows developers to isolate loaded assemblies into different groups to ensure that assembly versions don’t conflict.

Każdy plugin dostaje własny kontekst. Jego zależności są w nim oddzielone od reszty procesu.

using System.Reflection;
using System.Runtime.Loader;

public class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        string path = _resolver.ResolveAssemblyToPath(assemblyName);
        return path != null ? LoadFromAssemblyPath(path) : null;
    }
}

AssemblyDependencyResolver (też z .NET Core 3.0) czyta plik .deps.json pluginu (generowany przez dotnet build) i potrafi znaleźć ścieżki do wszystkich zależności pluginu - osobnych dla każdego.

Konfiguracja projektu pluginu #

Aby plugin był gotowy do dynamicznego ładowania, w jego .csproj dodaje się:

<PropertyGroup>
  <EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>

Skutek: dotnet build skopiuje wszystkie zależności pluginu do folderu output. Bez tego po wczytaniu DLL-a host nie znalazłby Newtonsoft.Json.dll (lub innej zależnej biblioteki) pluginu.

Krytyczna pułapka: <Private>false</Private> w referencji do PluginBase #

W .csproj pluginu referencja do projektu PluginBase musi wyglądać tak:

<ItemGroup>
  <ProjectReference Include="..\PluginBase\PluginBase.csproj">
    <Private>false</Private>
    <ExcludeAssets>runtime</ExcludeAssets>
  </ProjectReference>
</ItemGroup>

<Private>false</Private> mówi: nie kopiuj PluginBase.dll do output pluginu. Dlaczego to ważne?

Gdyby plugin miał własną kopię PluginBase.dll, CLR potraktowałby IPlugin z hosta i IPlugin z pluginu jako dwa różne typy - mimo identycznej definicji. IsAssignableFrom zwróciłby false. Plugin nigdy by się nie odnalazł. Pułapka bardzo subtelna, lecz brutalna.

4. Pułapki produkcyjne #

Bezpieczeństwo - plugin to obcy kod w Twoim procesie #

Najważniejsze ostrzeżenie z dokumentacji Microsoftu, podane wprost:

Untrusted code cannot be safely loaded into a trusted .NET process. To provide a security or reliability boundary, consider a technology provided by your OS or virtualization platform.

Plugin wykonuje się w tym samym procesie co host - z dostępem do tej samej pamięci, plików i sieci. Złośliwy plugin może czytać sekretne klucze z konfiguracji, wysyłać dane do sieci, modyfikować bazę. AssemblyLoadContext nie jest sandboxem. Granicę bezpieczeństwa daje dopiero system operacyjny (osobny proces z mniejszymi uprawnieniami, kontener Docker, maszyna wirtualna).

ReflectionTypeLoadException przy niekompletnym assembly #

assembly.GetTypes() rzuca ReflectionTypeLoadException, gdy choć jeden typ w assembly nie da się załadować (brak zależności, niezgodność wersji). Wyjątek zawiera w LoaderExceptions listę szczegółowych błędów per typ. Bezpieczniejszy wariant - GetExportedTypes (tylko publiczne) albo defensywne przechwycenie:

Type[] types;
try
{
    types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
    // Bierzemy te, które udało się załadować, ignorujemy resztę
    types = ex.Types.Where(t => t != null).ToArray();
    foreach (var inner in ex.LoaderExceptions)
        Console.Error.WriteLine($"Loader error: {inner?.Message}");
}

Blokada pliku przez LoadFrom #

Assembly.LoadFrom na Windowsie blokuje plik na dysku - nie usuniesz ani nie nadpiszesz .dll, dopóki proces żyje. To zabija scenariusze “hot reload” (podmień plugin, wczytaj nową wersję). Rozwiązanie - AssemblyLoadContext z IsCollectible = true (.NET Core 3.0+) pozwala wyładować kontekst i odzyskać blokadę pliku.

Konflikt wersji IPlugin #

Opisana wyżej pułapka z <Private>false</Private>. Jeśli plugin “nieświadomie” dostarcza własną kopię PluginBase.dll, host i plugin widzą inne typy IPlugin. IsAssignableFrom zwraca false. Plugin “znika” mimo prawidłowej implementacji. Diagnostyka: Console.WriteLine(typeof(IPlugin).AssemblyQualifiedName) w obu miejscach - identyczne ciągi to ten sam typ, różne to różne typy.

5. Gdzie spotkasz ten wzorzec w realnym świecie #

Architektura pluginów nie jest egzotyczna - leży u podstaw większości narzędzi, których codziennie używasz:

NarzędzieCo ładuje dynamicznie
Visual Studio / RiderRozszerzenia (.vsix, .dll)
Unity EngineSkompilowane skrypty C# użytkownika
ASP.NET Core MVCSkanuje assembly w poszukiwaniu kontrolerów i akcji
xUnit / NUnitZnajduje metody oznaczone [Fact]/[Test] przez refleksję na assembly testów
Entity Framework CoreSkanuje encje, atrybuty, konfiguracje migracji
Gry z modami (Skyrim, Minecraft)Wczytuje user-made DLL-e/JAR-y
Kontenery DI (Autofac, MS DI)Auto-rejestracja serwisów ze skanowania assembly

Zauważ - wszystkie te systemy używają tego samego wzorca: kontrakt + skanowanie + dynamiczna instancjacja.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Assembly - jednostka wdrożenia .NET; .dll/.exe z kodem IL i metadanymi; klasa System.Reflection.Assembly
  • Trzy projekty wzorca plugin - PluginBase (interfejs), Plugin*.dll (implementacje), Host (loader)
  • Skanowanie typów - assembly.GetTypes() + filtr przez IsAssignableFrom + !IsInterface && !IsAbstract + Activator.CreateInstance + rzutowanie na interfejs
  • AssemblyLoadContext (.NET Core 3.0+) - izolacja wersji zależności między pluginami, fundament profesjonalnych systemów rozszerzeń
  • AssemblyDependencyResolver + <EnableDynamicLoading> - automatyczne kopiowanie zależności pluginu, czytanie .deps.json
  • Krytyczna pułapka - <Private>false</Private> na referencji do PluginBase w pluginie zapobiega dublowaniu interfejsu
  • Bezpieczeństwo - plugin wykonuje się w procesie hosta, AssemblyLoadContext nie jest sandboxem; granica zaufania to system operacyjny lub wirtualizacja
  • Inne pułapki - ReflectionTypeLoadException, blokada pliku przez LoadFrom, konflikt typów z różnych kontekstów

W następnym wpisie Wielowątkowość i programowanie równoległe wkroczymy w świat wątków - od klasy Thread przez ThreadPool do Task Parallel Library z Parallel.For i PLINQ. Plus synchronizacja (lock, Interlocked), pułapki race condition i deadlock oraz kiedy paralelizacja szkodzi bardziej niż pomaga.

Quiz i zadanie poniżej. Zadanie symuluje pełen mechanizm pluginów w pojedynczym pliku przez Assembly.GetExecutingAssembly() - to dokładnie ten sam wzorzec, jedyna różnica to źródło Assembly (Assembly.LoadFrom("plugin.dll") w realnym świecie).

Podobne wpisy

🔗 Linkują tu