diff --git a/SfeduSchedule.Plugin.Abstractions/IPlugin.cs b/SfeduSchedule.Plugin.Abstractions/IPlugin.cs new file mode 100644 index 0000000..ef08039 --- /dev/null +++ b/SfeduSchedule.Plugin.Abstractions/IPlugin.cs @@ -0,0 +1,13 @@ +namespace SfeduSchedule.Plugin.Abstractions; + +// Базовый контракт плагина (общий для хоста и плагинов) +public interface IPlugin +{ + string Name { get; } + + // Регистрация сервисов плагина в DI (выполняется до Build()) + void ConfigureServices(IServiceCollection services); + + // Регистрация маршрутов (Minimal API) плагина после Build() + void MapEndpoints(IEndpointRouteBuilder endpoints); +} \ No newline at end of file diff --git a/SfeduSchedule.Plugin.Abstractions/SfeduSchedule.Plugin.Abstractions.csproj b/SfeduSchedule.Plugin.Abstractions/SfeduSchedule.Plugin.Abstractions.csproj new file mode 100644 index 0000000..0ad852d --- /dev/null +++ b/SfeduSchedule.Plugin.Abstractions/SfeduSchedule.Plugin.Abstractions.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + Library + + + diff --git a/SfeduSchedule.Plugin.Sample/Plugin.cs b/SfeduSchedule.Plugin.Sample/Plugin.cs new file mode 100644 index 0000000..64cc96e --- /dev/null +++ b/SfeduSchedule.Plugin.Sample/Plugin.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using SfeduSchedule.Plugin.Abstractions; + +namespace SfeduSchedule.Plugin.Sample; + +// Пример сервиса плагина +public interface IGreeter +{ + string Greet(string name); +} + +public sealed class Greeter : IGreeter +{ + public string Greet(string name) => $"Hello, {name} from {nameof(Plugin)}!"; +} + +public sealed class SamplePlugin : IPlugin +{ + public string Name => "Sample"; + + public void ConfigureServices(IServiceCollection services) + { + // Регистрируем DI-сервисы плагина + services.AddScoped(); + // Можно регистрировать любые IHostedService, Options и т.д. + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + // Пример Minimal API эндпоинта + endpoints.MapGet("/plugins/sample/hello", (IGreeter greeter) => + { + return Results.Ok(new { message = greeter.Greet("world") }); + }); + } +} + +// Пример MVC-контроллера из плагина +[ApiController] +[Route("plugins/sample/[controller]")] +public class EchoController : ControllerBase +{ + [HttpGet("{text}")] + public IActionResult Get(string text) => Ok(new { echo = text, from = "Plugin.Sample" }); +} \ No newline at end of file diff --git a/SfeduSchedule.Plugin.Sample/SfeduSchedule.Plugin.Sample.csproj b/SfeduSchedule.Plugin.Sample/SfeduSchedule.Plugin.Sample.csproj new file mode 100644 index 0000000..0a44fa7 --- /dev/null +++ b/SfeduSchedule.Plugin.Sample/SfeduSchedule.Plugin.Sample.csproj @@ -0,0 +1,21 @@ + + + + SfeduSchedule.Plugin.Sample.plugin + net9.0 + enable + + true + enable + Library + enable + + + + + + + + + + diff --git a/SfeduSchedule.sln b/SfeduSchedule.sln index 50668ae..35476ce 100644 --- a/SfeduSchedule.sln +++ b/SfeduSchedule.sln @@ -2,6 +2,10 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule", "SfeduSchedule\SfeduSchedule.csproj", "{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule.Plugin.Abstractions", "SfeduSchedule.Plugin.Abstractions\SfeduSchedule.Plugin.Abstractions.csproj", "{B2E8DAD7-7373-4155-B230-4E53DFC04445}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule.Plugin.Sample", "SfeduSchedule.Plugin.Sample\SfeduSchedule.Plugin.Sample.csproj", "{B2B6D730-46AE-40ED-815F-81176FB4E545}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +16,13 @@ Global {57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Debug|Any CPU.Build.0 = Debug|Any CPU {57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.ActiveCfg = Release|Any CPU {57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.Build.0 = Release|Any CPU + {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Release|Any CPU.Build.0 = Release|Any CPU + {B2B6D730-46AE-40ED-815F-81176FB4E545}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2B6D730-46AE-40ED-815F-81176FB4E545}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2B6D730-46AE-40ED-815F-81176FB4E545}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2B6D730-46AE-40ED-815F-81176FB4E545}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/SfeduSchedule/PluginLoader.cs b/SfeduSchedule/PluginLoader.cs new file mode 100644 index 0000000..f46a58c --- /dev/null +++ b/SfeduSchedule/PluginLoader.cs @@ -0,0 +1,64 @@ +using System.Reflection; +using System.Runtime.Loader; +using SfeduSchedule.Plugin.Abstractions; + +public sealed record LoadedPlugin(IPlugin Instance, Assembly Assembly, PluginLoadContext Context); + +public static class PluginLoader +{ + // Загружаем все сборки *.plugin.dll из папки плагинов + public static IReadOnlyList LoadPlugins(string pluginsDir) + { + var result = new List(); + + if (!Directory.Exists(pluginsDir)) + return result; + + foreach (var file in Directory.EnumerateFiles(pluginsDir, "*.plugin.dll", SearchOption.AllDirectories)) + { + var path = Path.GetFullPath(file); + var alc = new PluginLoadContext(path); + var asm = alc.LoadFromAssemblyPath(path); + + // Ищем реализацию IPlugin + var pluginType = asm + .GetTypes() + .FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface); + + if (pluginType is null) + continue; + + var instance = (IPlugin)Activator.CreateInstance(pluginType)!; + result.Add(new LoadedPlugin(instance, asm, alc)); + } + + return result; + } +} + +// Отдельный контекст загрузки для изоляции зависимостей плагина +public sealed class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public PluginLoadContext(string pluginMainAssemblyPath) + : base(isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginMainAssemblyPath); + } + + // Разрешаем управляемые зависимости плагина из его папки. + // Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, Plugin.Abstractions). + protected override Assembly? Load(AssemblyName assemblyName) + { + var path = _resolver.ResolveAssemblyToPath(assemblyName); + return path is null ? null : LoadFromAssemblyPath(path); + } + + // Нативные зависимости (если есть) + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path); + } +} \ No newline at end of file diff --git a/SfeduSchedule/Program.cs b/SfeduSchedule/Program.cs index c7fc75a..21d409f 100644 --- a/SfeduSchedule/Program.cs +++ b/SfeduSchedule/Program.cs @@ -6,6 +6,7 @@ using SfeduSchedule.Jobs; using SfeduSchedule.Services; using X.Extensions.Logging.Telegram.Extensions; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc.ApplicationParts; var builder = WebApplication.CreateBuilder(args); @@ -26,7 +27,7 @@ if (!Directory.Exists(dataDirectory)) } GlobalVariables.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt"); - +var pluginsPath = Path.Combine(dataDirectory, "Plugins"); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); @@ -45,11 +46,24 @@ if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken)) }; }); -builder.Services.AddControllers(); +// Включаем MVC контроллеры +var mvcBuilder = builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration); +// Загружаем плагины (изолированно), даём им зарегистрировать DI и подключаем контроллеры +var loaded = PluginLoader.LoadPlugins(pluginsPath); +foreach (var p in loaded) +{ + // DI из плагина + p.Instance.ConfigureServices(builder.Services); + + // Подключаем контроллеры плагина + mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly)); +} + + var jobKey = new JobKey("UpdateJWTJob"); if (string.IsNullOrEmpty(preinstalledJwtToken)) @@ -175,6 +189,13 @@ app.MapGet("/", async context => app.MapControllers(); +// Маршруты Minimal API из плагинов +foreach (var p in loaded) +{ + logger.LogInformation("Mapping endpoints for plugin: {PluginName}", p.Instance.Name); + p.Instance.MapEndpoints(app); +} + app.UseRateLimiter(); app.Run(); \ No newline at end of file diff --git a/SfeduSchedule/SfeduSchedule.csproj b/SfeduSchedule/SfeduSchedule.csproj index 3e3865b..3bc8305 100644 --- a/SfeduSchedule/SfeduSchedule.csproj +++ b/SfeduSchedule/SfeduSchedule.csproj @@ -18,4 +18,8 @@ + + + +