65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET Core Web API:插件控制器和服务

starIconstarIconstarIconstarIconstarIcon

5.00/5 (23投票s)

2022年1月3日

CPOL

10分钟阅读

viewsIcon

45247

downloadIcon

766

单体应用程序和微服务爆炸之间的折衷

引言

我发现维护企业级Web API往往会导致大型单体应用程序,尤其是在Microsoft的MVC框架等旧技术中。微服务提供了一个很好的解决方案来分隔独立的SaaS服务,但我注意到这会导致大量的独立存储库,增加了自动化部署的复杂性,涉及IIS配置,并且如果全局策略(安全性、日志记录、数据库连接等)发生变化,每个微服务都需要进行修改。在转向ASP.NET Core后,我希望探索使用运行时插件控制器/服务。这里的想法是,核心(并非双关语)应用程序处理所有常见策略,并且这些策略的更新会平等地影响所有控制器/服务。此外,由于附加的控制器/服务在运行时简单地添加到核心应用程序中,因此无需启动服务器/容器或管理微服务的IIS配置。这种方法可以用于许可模型中,仅提供客户支付的服务,或者用于在不部署核心应用程序的情况下向Web API添加新功能。无论优点和缺点如何,本文的重点是演示如何为ASP.NET Core Web API应用程序实现适当的插件架构,使其成为架构考虑工具箱中的另一个工具。

看似简单

基本概念看起来很简单。我们从两个项目开始

  • 参考的web-api项目(我从我的文章《如何开始任何.NET Core Web API项目》的代码开始)
  • 一个.NET Core 3.1库。奇怪的是,在Visual Studio 2019中创建它并不容易,至少在我的VS 2019配置方式下。

当您下载上面提到的参考项目并运行它时,它应该为您的本地IIS配置一个名为“Demo”的站点,您应该会看到

这只不过是控制器响应了一些文本。

创建.NET Core 3.1库

该库应创建为参考项目中“Application”文件夹的同级。我发现我必须从命令行执行此操作。我们将创建两个项目

  • 插件
  • 接口(稍后将使用)

打开CLI并输入

dotnet new classlib -n "Plugin" -lang C#
dotnet new classlib -n "Interfaces" -lang C#

您现在应该看到Application文件夹以及我们刚刚创建的两个带有其项目的文件夹(忽略我的“Article”文件夹)。例如

将项目添加到解决方案

InterfacesPlugin文件夹中的项目添加到Visual Studio中的解决方案。完成后,您应该会看到

设置目标框架

接下来,打开这两个项目的属性,并将目标框架设置为.NET Core 3.1

无论依赖关系如何,都构建所有项目

在Visual Studio的工具 => 选项中,确保取消选中“仅构建启动项目和运行时的依赖项”。

这样做的原因是,主项目不引用插件,并且任何更改都不会构建,除非您明确构建它们——如果选中此复选框,对未引用项目进行更改将导致大量头痛的“我为什么看不到我的更改!”

添加对Microsoft.AspNetCore.Mvc的引用

Microsoft.AspNetCore.Mvc的引用添加到“plugin”项目。

插件控制器

我们将从一个只有控制器的简单插件开始。

将默认类“Class1.cs”重命名为“PluginController.cs”,并从非常基础的开始

using Microsoft.AspNetCore.Mvc;

namespace Plugin
{
  [ApiController]
  [Route("[controller]")]
  public class PluginController : ControllerBase
  {
    public PluginController()
    {
    }

    [HttpGet("Version")]
    public object Version()
    {
      return "Plugin Controller v 1.0";
    }
  }
}

加载程序集并告诉AspNetCore使用它

这是有趣的部分。将以下内容添加到Startup.cs中的ConfigureServices方法

Assembly assembly = 
 Assembly.LoadFrom(@"C:\projects\PluginNetCoreDemo\Plugin\bin\Debug\netcoreapp3.1\Plugin.dll");
var part = new AssemblyPart(assembly);
services.AddControllers().PartManager.ApplicationParts.Add(part);

是的,我硬编码了路径——这里的重点是演示插件控制器是如何连接的,而不是讨论如何确定插件列表和路径。这里有趣的是这一行

services.AddControllers().PartManager.ApplicationParts.Add(part);

不幸的是,关于ApplicationPartManager的作用,文档或描述很少,除了“管理MVC应用程序的部件和功能”之外。然而,通过谷歌搜索“什么是ApplicationPartManager”,此链接提供了进一步有用的描述。

上面的代码还需要

using Microsoft.AspNetCore.Mvc.ApplicationParts;

构建项目后,您应该能够导航到localhost/Demo/plugin/version并看到

这表明控制器端点已连接并可通过浏览器访问!

但实际上并非那么简单

一旦我们想做一些更有趣的事情,比如使用插件中定义的服务,生活就会变得复杂一些。原因是插件中没有任何东西可以连接服务——没有Startup类,也没有ConfigureServices实现。尽管我尝试过在主应用程序中使用反射来解决这个问题,但我遇到了一些障碍,特别是在获取AddSingleton扩展方法的MethodInfo对象时。因此,我提出了这里描述的方法,我发现它实际上更灵活。

初始化插件中的服务

还记得前面创建的“Interfaces”项目吗?这就是我们要开始使用它的地方。首先,在该项目中创建一个简单的接口

using Microsoft.Extensions.DependencyInjection;

namespace Interaces
{
  public interface IPlugin 
  {
    void Initialize(IServiceCollection services);
  }
}

请注意,这需要添加包Microsoft.Extensions.DependencyInjection——请确保您使用最新的3.1.x版本,因为我们正在使用.NET Core 3.1!

Plugin项目中,创建一个简单的服务

namespace Plugin
{
  public class PluginService
  {
    public string Test()
    {
      return "Tested!";
    }
  }
}

Plugin项目中,创建一个实现它的类,以初始化服务为例

using Microsoft.Extensions.DependencyInjection;

using Interfaces;

namespace Plugin
{
  public class Plugin : IPlugin
  {
    public void Initialize(IServiceCollection services)
    {
      services.AddSingleton<PluginService>();
    }
  }
}

现在将服务添加到控制器的构造函数中,它将被注入

using Microsoft.AspNetCore.Mvc;

namespace Plugin
{
  [ApiController]
  [Route("[controller]")]
  public class PluginController : ControllerBase
  {
    private PluginService ps;

    public PluginController(PluginService ps)
    {
      this.ps = ps;
    }

    [HttpGet("Version")]
    public object Version()
    {
      return $"Plugin Controller v 1.0 {ps.Test()}";
    }
  }
}

请注意,此时,如果我们尝试运行应用程序,我们将看到此错误

原因是我们在主应用程序中没有调用Initialize方法,以便插件可以注册服务。我们将在ConfigureServices方法中使用反射来完成此操作

var atypes = assembly.GetTypes();
var types = atypes.Where(t => t.GetInterface("IPlugin") != null).ToList();
var aservice = types[0];
var initMethod = aservice.GetMethod("Initialize", BindingFlags.Public | BindingFlags.Instance);
var obj = Activator.CreateInstance(aservice);
initMethod.Invoke(obj, new object[] { services });

现在我们看到控制器正在使用该服务!

上面的代码相当糟糕,所以让我们重构它。我们还将让应用程序引用Interfaces项目,这样我们就可以这样做

var atypes = assembly.GetTypes();
var pluginClass = atypes.SingleOrDefault(t => t.GetInterface(nameof(IPlugin)) != null);

if (pluginClass != null)
{
  var initMethod = pluginClass.GetMethod(nameof(IPlugin.Initialize), 
                   BindingFlags.Public | BindingFlags.Instance);
  var obj = Activator.CreateInstance(pluginClass);
  initMethod.Invoke(obj, new object[] { services });
}

这更简洁,使用了nameof,而且我们也不关心插件是否实现了这个接口的类——也许它没有任何服务。

所以现在,我们有了可以使用自己服务的插件。重要的是要注意,这种方法允许插件根据需要初始化服务:作为单例、作用域或瞬态服务。

但是如何将服务暴露给应用程序呢?

将插件服务暴露给应用程序

这就是接口变得更有用的地方。让我们将服务重构为

using Interfaces;

namespace Plugin
{
  public class PluginService : IPluginService
  {
    public string Test()
    {
      return "Tested!";
    }
  }
}

并将IPluginService定义为

namespace Interfaces
{
  public interface IPluginService
  {
    string Test();
  }
}

现在让我们回到我们的Public应用程序控制器,并实现IPluginService的依赖注入

using System;

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

using Interfaces;

namespace Demo.Controllers
{
  [ApiController]
  [Route("[controller]")]
  public class Public : ControllerBase
  {
    private IPluginService ps;

    public Public(IPluginService ps)
    {
      this.ps = ps;
    }

    [AllowAnonymous]
    [HttpGet("Version")]
    public object Version()
    {
      return new { Version = "1.00", PluginSays = ps.Test() };
    }
  }
}

同样,这次对于应用程序的public/version路由,我们得到

原因是插件将其服务初始化为服务类型

services.AddSingleton<PluginService>();

这行代码现在必须更改为

services.AddSingleton<IPluginService, PluginService>();

现在我们看到了

但是我们破坏了插件

因此,我们还必须重构插件控制器,以使用接口进行依赖注入,而不是具体的服务类型

using Microsoft.AspNetCore.Mvc;

using Interfaces;

namespace Plugin
{
  [ApiController]
  [Route("[controller]")]
  public class PluginController : ControllerBase
  {
    private IPluginService ps;

    public PluginController(IPluginService ps)
    {
      this.ps = ps;
    }

    [HttpGet("Version")]
    public object Version()
    {
      return $"Plugin Controller v 1.0 {ps.Test()}";
    }
  }
}

请注意更改为使用IPluginService。现在一切又恢复正常了

将应用程序服务暴露给插件

最后,我们想测试将应用程序服务暴露给插件。同样,服务必须在Interfaces项目中用接口初始化,以便应用程序和插件都可以共享它

namespace Interfaces
{
  public interface IApplicationService
  {
    string Test();
  }
}

以及我们的应用程序服务

using Interfaces;

namespace Demo.Services
{
  public class ApplicationService : IApplicationService
  {
    public string Test()
    {
      return "Application Service Tested!";
    }
  }
}

及其初始化

services.AddSingleton<IApplicationService, ApplicationService>();

现在在我们的插件中,将指示应该注入此接口

using Microsoft.AspNetCore.Mvc;

using Interfaces;

namespace Plugin
{
  [ApiController]
  [Route("[controller]")]
  public class PluginController : ControllerBase
  {
    private IPluginService ps;
    private IApplicationService appSvc;

    public PluginController(IPluginService ps, IApplicationService appSvc)
    {
      this.ps = ps;
      this.appSvc = appSvc;
    }

    [HttpGet("Version")]
    public object Version()
    {
      return $"Plugin Controller v 1.0 {ps.Test()} {appSvc.Test()}";
    }
  }
}

我们看到

引用其他插件服务的插件

对于只提供服务的插件,也可以使用相同的方法。例如,让我们添加另一个项目Plugin2,它只实现一个服务

using Interfaces;

namespace Plugin2
{
  public class Plugin2Service : IPlugin2Service
  {
    public int Add(int a, int b)
    {
      return a + b;
    }
  }
}

using Microsoft.Extensions.DependencyInjection;

using Interfaces;

namespace Plugin2
{
  public class Plugin2 : IPlugin
  {
    public void Initialize(IServiceCollection services)
    {
      services.AddSingleton<IPlugin2Service, Plugin2Service>();
    }
  }
}

在应用程序的ConfigureServices方法中,我们将为第二个插件添加硬编码的初始化(不要这样在家操作!)

Assembly assembly2 = Assembly.LoadFrom
         (@"C:\projects\PluginNetCoreDemo\Plugin2\bin\Debug\netcoreapp3.1\Plugin2.dll");
var part2 = new AssemblyPart(assembly2);
services.AddControllers().PartManager.ApplicationParts.Add(part2);

var atypes2 = assembly2.GetTypes();
var pluginClass2 = atypes2.SingleOrDefault(t => t.GetInterface(nameof(IPlugin)) != null);

if (pluginClass2 != null)
{
  var initMethod = pluginClass2.GetMethod(nameof(IPlugin.Initialize), 
                   BindingFlags.Public | BindingFlags.Instance);
  var obj = Activator.CreateInstance(pluginClass2);
  initMethod.Invoke(obj, new object[] { services });
}

我希望这很明显只是为了演示目的,您绝不应该在ConfigureServices方法中硬编码插件或复制并粘贴初始化代码!

而且,在我们的第一个插件中

using Microsoft.AspNetCore.Mvc;

using Interfaces;

namespace Plugin
{
  [ApiController]
  [Route("[controller]")]
  public class PluginController : ControllerBase
  {
    private IPluginService ps;
    private IPlugin2Service ps2;
    private IApplicationService appSvc;

    public PluginController
           (IPluginService ps, IPlugin2Service ps2, IApplicationService appSvc)
    {
      this.ps = ps;
      this.ps2 = ps2;
      this.appSvc = appSvc;
    }

    [HttpGet("Version")]
    public object Version()
    {
      return $"Plugin Controller v 1.0 {ps.Test()} 
             {appSvc.Test()} and 1 + 2 = {ps2.Add(1, 2)}";
    }
  }
}

我们看到

演示了第一个插件正在使用由第二个插件提供的服务,所有这些都得益于ASP.NET提供的依赖注入。

通用插件加载器

一种方法是在appsettings.json文件中指定插件

"Plugins": [
  { "Path": "<a href="file:///C://projects//PluginNetCoreDemo//Plugin//bin//Debug//
              netcoreapp3.1//Plugin.dll">C:\\projects\\PluginNetCoreDemo\\Plugin\\bin\\
              Debug\\netcoreapp3.1\\Plugin.dll</a>" },
  { "Path": "C:\\projects\\PluginNetCoreDemo\\Plugin2\\bin\\Debug\\netcoreapp3.1\\Plugin2.dll" }
]

我选择提供完整路径而不是使用Assembly.GetExecutingAssembly().Location,因为我认为不假设插件的DLL在应用程序的执行位置更灵活。

AppSettings类被修改为列出插件

public class AppSettings
{
  public static AppSettings Settings { get; set; }

  public AppSettings()
  {
    Settings = this;
  }

  public string Key1 { get; set; }
  public string Key2 { get; set; }
  public List<Plugin> Plugins { get; set; } = new List<Plugin>();
}

我们现在可以实现一个扩展方法来加载插件并调用服务初始化器(如果存在)

public static class ServicePluginExtension
{
  public static IServiceCollection LoadPlugins(this IServiceCollection services, 
                                               AppSettings appSettings)
  {
    AppSettings.Settings.Plugins.ForEach(p =>
    {
      Assembly assembly = Assembly.LoadFrom(p.Path);
      var part = new AssemblyPart(assembly);

      // services.AddControllers().PartManager.ApplicationParts.Add(part);
      // Correction from Colin O'Keefe so that things like customizing the routing or API versioning works,
      // which gets ignored using the above commented out AddControllers line.
      services.AddControllersWithViews().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part));

      var atypes = assembly.GetTypes();
      var pluginClass = atypes.SingleOrDefault(t => t.GetInterface(nameof(IPlugin)) != null);

      if (pluginClass != null)
      {
        var initMethod = pluginClass.GetMethod(nameof(IPlugin.Initialize), 
                         BindingFlags.Public | BindingFlags.Instance);
        var obj = Activator.CreateInstance(pluginClass);
        initMethod.Invoke(obj, new object[] { services });
      }
    });

    return services;
  }
}    

我们通过以下方式在ConfigureServices方法中调用它

services.LoadPlugins();

当然,还有其他方法可以实现。

仅加载控制器的有趣替代方案

如果你唯一需要做的就是加载控制器,我偶然发现了这个实现,坦率地说,这对我来说是不可思议的,因为我不知道IApplicationProvider是如何工作的。

using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;

... 

public class GenericControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
  public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
  {
    Assembly assembly = Assembly.LoadFrom(p.Path);
    var atypes = assembly.GetTypes();
    var types = atypes.Where(t => t.BaseType == typeof(ControllerBase)).ToList();
    feature.Controllers.Add(types[0].GetTypeInfo());
  }
}

并使用以下方式调用

services.AddControllers().PartManager.FeatureProviders.Add
                            (new GenericControllerFeatureProvider());

这种实现的缺点是,我无法找到任何IServiceCollection实例,因此无法调用插件来注册其服务。但是,如果您的插件中只有控制器(它们仍然可以引用应用程序中的服务),那么这是另一种可行的方法。

结论

正如我的另一篇文章《客户端到服务器文件/数据流》一样,我发现互联网上非常缺乏关于如何实现插件控制器以及在应用程序和插件之间共享服务的简明指南。希望本文能填补这一空白。

需要注意的一点是——我没有实现程序集解析器,以防插件引用的DLL位于其自己的目录中,而不是应用程序的执行位置。

理想情况下,应用程序和插件(或插件和插件之间)不应共享服务,因为这会通过“接口”库(或更糟的是,多个库)创建耦合,如果更改实现,则接口必须更改,然后所有内容都需要重新构建。可能的例外是高度稳定的服务,也许是数据库服务。一个有趣的想法是,主web-api应用程序只是插件和公共服务(日志记录、身份验证、授权等)的初始化——这有一定的吸引力,它让我想起了《2001太空漫游》中HAL 9000的配置方式——可怜的HAL在模块被拔掉时开始退化!然而,如前所述,这种方法可能会导致接口依赖,除非您的插件是自治的。

无论如何,这提供了一种有趣的替代典型实现的方式

  • 单体应用
  • 直接引用DLL的应用程序(准单体)
  • 微服务

我希望您发现这是创建ASP.NET Core web API工具箱中的另一个选项。

历史

  • 2022年1月3日:初始版本
  • 2022年2月8日:根据Colin O"Keefe的反馈,更新了文章代码中的LoadPlugins。此更改包含在下载中。
© . All rights reserved.