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ędzie | Co ładuje dynamicznie |
|---|---|
| Visual Studio / Rider | Rozszerzenia (.vsix, .dll) |
| Unity Engine | Skompilowane skrypty C# użytkownika |
| ASP.NET Core MVC | Skanuje assembly w poszukiwaniu kontrolerów i akcji |
| xUnit / NUnit | Znajduje metody oznaczone [Fact]/[Test] przez refleksję na assembly testów |
| Entity Framework Core | Skanuje 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/.exez kodem IL i metadanymi; klasaSystem.Reflection.Assembly - Trzy projekty wzorca plugin -
PluginBase(interfejs),Plugin*.dll(implementacje),Host(loader) - Skanowanie typów -
assembly.GetTypes()+ filtr przezIsAssignableFrom+!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 doPluginBasew pluginie zapobiega dublowaniu interfejsu - Bezpieczeństwo - plugin wykonuje się w procesie hosta,
AssemblyLoadContextnie jest sandboxem; granica zaufania to system operacyjny lub wirtualizacja - Inne pułapki -
ReflectionTypeLoadException, blokada pliku przezLoadFrom, 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
Refleksja i atrybuty w C# - System.Reflection, Type, Activator, własne atrybuty
Odczytywanie metadanych w trakcie wykonania, dynamiczne tworzenie obiektów i wywoływanie metod przez refleksję, atrybuty jako żółte karteczki dla kodu, projektowanie własnych atrybutów z AttributeUsage. Ósma część kursu C#.
Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK
Mechanizmy obronne bazy danych - Primary Key (INT IDENTITY kontra GUID), Foreign Key i integralność referencyjna, trzy strategie ON DELETE (CASCADE/SET NULL/NO ACTION), constraints UNIQUE i CHECK jako walidacja na poziomie bazy. Dwudziesta czwarta część kursu C#.
SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN
Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.
🔗 Linkują tu