从零开始实现ASP.NET Core MVC的插件式开发(三) - 如何在运行时启用组件

标题:从零开始实现ASP.NET Core MVC的插件式开发(三) - 如何在运行时启用组件
做者:Lamond Lu
地址:http://www.javashuo.com/article/p-fmzvvncn-eq.html
源代码:https://github.com/lamondlu/DynamicPluginshtml

前情回顾

在前面两篇中,我为你们演示了如何使用Application Part动态加载控制器和视图,以及如何建立插件模板来简化操做。
在上一篇写完以后,我忽然想到了一个问题,若是像前两篇所设计那个来构建一个插件式系统,会有一个很严重的问题,即git

当你添加一个插件以后,整个程序不能马上启用该插件,只有当重启整个ASP.NET Core应用以后,才能正确的加载插件。由于全部插件的加载都是在程序启动时ConfigureService方法中配置的。github

这种方式的插件系统会很难用,咱们指望的效果是在运行时动态启用和禁用插件,那么有没有什么解决方案呢?答案是确定的。下面呢,我将一步一步说明一下本身的思路、编码中遇到的问题,以及这些问题的解决方案。json

为了完成这个功能,我走了许多弯路,当前这个方案可能不是最好的,可是确实是一个可行的方案,若是你们有更好的方案,咱们能够一块儿讨论一下。c#

在Action中激活组件

当遇到这个问题的时候,个人第一思路就是将ApplicationPartManager加载插件库的代码移动到某个Action中。因而我就在主站点中建立了一个PluginsController, 并在启用添加了一个名为Enable的Action方法。app

public class PluginsController : Controller
{
    public IActionResult Enable()
    {
        var assembly = Assembly.LoadFile(AppDomain.CurrentDomain.BaseDirectory + "DemoPlugin1\\DemoPlugin1.dll");
        var viewAssembly = Assembly.LoadFile(AppDomain.CurrentDomain.BaseDirectory + "DemoPlugin1\\DemoPlugin1.Views.dll");
        var viewAssemblyPart = new CompiledRazorAssemblyPart(viewAssembly);

        var controllerAssemblyPart = new AssemblyPart(assembly);
        _partManager.ApplicationParts.Add(controllerAssemblyPart);
        _partManager.ApplicationParts.Add(viewAssemblyPart);

        return Content("Enabled");
    }
}

修改代码以后,运行程序,这里咱们首先调用/Plugins/Enable来尝试激活组件,激活以后,咱们再次调用/Plugin1/HelloWorldide

这里会发现程序返回了404, 即控制器和视图没有正确的激活。函数

这里你可能有疑问,为何会激活失败呢?编码

这里的缘由是,只有当ASP.NET Core应用启动时,才会去ApplicationPart管理器中加载控制器与视图的程序集,因此虽然新的控制器程序集在运行时被添加到了ApplicationPart管理器中,可是ASP.NET Core不会自动进行更新操做,因此这里咱们须要寻找一种方式可以让ASP.NET Core从新加载控制器的方法。插件

经过查询各类资料,我最终找到了一个切入点,在ASP.NET Core 2.2中有一个类是ActionDescriptorCollectionProvider,它的子类DefaultActionDescriptorCollectionProvider是用来配置Controller和Action的。

源代码:

internal class DefaultActionDescriptorCollectionProvider : ActionDescriptorCollectionProvider
    {
        private readonly IActionDescriptorProvider[] _actionDescriptorProviders;
        private readonly IActionDescriptorChangeProvider[] _actionDescriptorChangeProviders;
        private readonly object _lock;
        private ActionDescriptorCollection _collection;
        private IChangeToken _changeToken;
        private CancellationTokenSource _cancellationTokenSource;
        private int _version = 0;

        public DefaultActionDescriptorCollectionProvider(
            IEnumerable<IActionDescriptorProvider> actionDescriptorProviders,
            IEnumerable<IActionDescriptorChangeProvider> actionDescriptorChangeProviders)
        {
            ...
            ChangeToken.OnChange(
                GetCompositeChangeToken,
                UpdateCollection);
        }
       
        public override ActionDescriptorCollection ActionDescriptors
        {
            get
            {
                Initialize();

                return _collection;
            }
        }

        ...

        private IChangeToken GetCompositeChangeToken()
        {
            if (_actionDescriptorChangeProviders.Length == 1)
            {
                return _actionDescriptorChangeProviders[0].GetChangeToken();
            }

            var changeTokens = new IChangeToken[_actionDescriptorChangeProviders.Length];
            for (var i = 0; i < _actionDescriptorChangeProviders.Length; i++)
            {
                changeTokens[i] = _actionDescriptorChangeProviders[i].GetChangeToken();
            }

            return new CompositeChangeToken(changeTokens);
        }

        ...

        private void UpdateCollection()
        {
            lock (_lock)
            {
                var context = new ActionDescriptorProviderContext();

                for (var i = 0; i < _actionDescriptorProviders.Length; i++)
                {
                    _actionDescriptorProviders[i].OnProvidersExecuting(context);
                }

                for (var i = _actionDescriptorProviders.Length - 1; i >= 0; i--)
                {
                    _actionDescriptorProviders[i].OnProvidersExecuted(context);
                }
                
                var oldCancellationTokenSource = _cancellationTokenSource;
           
                _collection = new ActionDescriptorCollection(
                    new ReadOnlyCollection<ActionDescriptor>(context.Results),
                    _version++);

                _cancellationTokenSource = new CancellationTokenSource();
                _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token);

                oldCancellationTokenSource?.Cancel();
            }
        }
    }
  • 这里ActionDescriptors属性中记录了当ASP.NET Core程序启动后,匹配到的全部Controller/Action集合。
  • UpdateCollection方法使用来更新ActionDescriptors集合的。
  • 在构造函数中设计了一个触发器,ChangeToken.OnChange(GetCompositeChangeToken,UpdateCollection)。这里程序会监听一个Token对象,当这个Token对象发生变化时,就自动触发UpdateCollection方法。
  • 这里Token是由一组IActionDescriptorChangeProvider接口对象组合而成的。

因此这里咱们就能够经过自定义一个IActionDescriptorChangeProvider接口对象,并在组件激活方法Enable中修改这个接口Token的方式,使DefaultActionDescriptorCollectionProvider中的CompositeChangeToken发生变化,从而实现控制器的从新装载。

使用IActionDescriptorChangeProvider在运行时激活控制器

这里咱们首先建立一个MyActionDescriptorChangeProvider类,并让它实现IActionDescriptorChangeProvider接口

public class MyActionDescriptorChangeProvider : IActionDescriptorChangeProvider
    {
        public static MyActionDescriptorChangeProvider Instance { get; } = new MyActionDescriptorChangeProvider();

        public CancellationTokenSource TokenSource { get; private set; }

        public bool HasChanged { get; set; }

        public IChangeToken GetChangeToken()
        {
            TokenSource = new CancellationTokenSource();
            return new CancellationChangeToken(TokenSource.Token);
        }
    }

而后咱们须要在Startup.csConfigureServices方法中,将MyActionDescriptorChangeProvider.Instance属性以单例的方式注册到依赖注入容器中。

public void ConfigureServices(IServiceCollection services)
    {
        ...

        services.AddSingleton<IActionDescriptorChangeProvider>(MyActionDescriptorChangeProvider.Instance);
        services.AddSingleton(MyActionDescriptorChangeProvider.Instance);
        
        ...
    }

最后咱们在Enable方法中经过两行代码来修改当前MyActionDescriptorChangeProvider对象的Token。

public class PluginsController : Controller
    {
        public IActionResult Enable()
        {
            var assembly = Assembly.LoadFile(AppDomain.CurrentDomain.BaseDirectory + "DemoPlugin1\\DemoPlugin1.dll");
            var viewAssembly = Assembly.LoadFile(AppDomain.CurrentDomain.BaseDirectory + "DemoPlugin1\\DemoPlugin1.Views.dll");
            var viewAssemblyPart = new CompiledRazorAssemblyPart(viewAssembly);

            var controllerAssemblyPart = new AssemblyPart(assembly);
            _partManager.ApplicationParts.Add(controllerAssemblyPart);
            _partManager.ApplicationParts.Add(viewAssemblyPart);
            
            MyActionDescriptorChangeProvider.Instance.HasChanged = true;
            MyActionDescriptorChangeProvider.Instance.TokenSource.Cancel();

            return Content("Enabled");
        }
    }

修改代码以后从新运行程序,这里咱们依然首先调用/Plugins/Enable,而后再次调用/Plugin1/Helloworld, 这时候你会发现Action被触发了,只是没有找到对应的Views。

如何解决插件的预编译Razor视图不能从新加载的问题?

经过以上的方式,咱们终于得到了在运行时加载插件控制器程序集的能力,可是插件的预编译Razor视图程序集没有被正确加载,这就说明IActionDescriptorChangeProvider只会触发控制器的从新加载,不会触发预编译Razor视图的从新加载。ASP.NET Core只会在整个应用启动时,才会加载插件的预编译Razor程序集,因此咱们并无得到在运行时从新加载预编译Razor视图的能力。

针对这一点,我也查阅了好多资料,最终也没有一个可行的解决方案,也许使用ASP.NET Core 3.0的Razor Runtime Compilation能够实现,可是在ASP.NET Core 2.2版本,咱们尚未得到这种能力。

为了越过这个难点,最终我仍是选择了放弃预编译Razor视图,改用原始的Razor视图。

由于在ASP.NET Core启动时,咱们能够在Startup.csConfigureServices方法中配置Razor视图引擎检索视图的规则。

这里咱们能够把每一个插件组织成ASP.NET Core MVC中一个Area, Area的名称即插件的名称, 这样咱们就能够将为Razor视图引擎的添加一个检索视图的规则,代码以下

services.Configure<RazorViewEngineOptions>(o =>
    {
        o.AreaViewLocationFormats.Add("/Modules/{2}/{1}/Views/{0}" + RazorViewEngine.ViewExtension);
    });

这里{2}表明Area名称, {1}表明Controller名称, {0}表明Action名称。

这里Modules是我从新建立的一个目录,后续全部的插件都会放置在这个目录中。

一样的,咱们还须要在Configure方法中为Area注册路由。

app.UseMvc(routes =>
    {
        routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");

        routes.MapRoute(
        name: "default",
        template: "Modules/{area}/{controller=Home}/{action=Index}/{id?}");
    });

由于咱们已经不须要使用Razor的预编译视图,因此Enable方法咱们的最终代码以下

public IActionResult Enable()
    {
        var assembly = Assembly.LoadFile(AppDomain.CurrentDomain.BaseDirectory + "Modules\\DemoPlugin1\\DemoPlugin1.dll");

        var controllerAssemblyPart = new AssemblyPart(assembly);
        _partManager.ApplicationParts.Add(controllerAssemblyPart);

        MyActionDescriptorChangeProvider.Instance.HasChanged = true;
        MyActionDescriptorChangeProvider.Instance.TokenSource.Cancel();

        return Content("Enabled");
    }

以上就是针对主站点的修改,下面咱们再来修改一下插件项目。

首先咱们须要将整个项目的Sdk类型改成由以前的Microsoft.Net.Sdk.Razor改成Microsoft.Net.Sdk.Web, 因为以前咱们使用了预编译的Razor视图,因此咱们使用了Microsoft.Net.Sdk.Razor,它会将视图编译为一个dll文件。可是如今咱们须要使用原始的Razor视图,因此咱们须要将其改成Microsoft.Net.Sdk.Web, 使用这个Sdk, 最终的Views文件夹中的文件会以原始的形式发布出来。

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
  </PropertyGroup>

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <OutputPath></OutputPath>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Razor" Version="2.2.0" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\DynamicPlugins.Core\DynamicPlugins.Core.csproj" />
  </ItemGroup>



</Project>

最后咱们须要在Plugin1Controller上添加Area配置, 并将编译以后的程序集以及Views目录放置到主站点项目的Modules目录中

[Area("DemoPlugin1")]
    public class Plugin1Controller : Controller
    {
        public IActionResult HelloWorld()
        {
            return View();
        }
    }

最终主站点项目目录结构

The files tree is:
=================

  |__ DynamicPlugins.Core.dll
  |__ DynamicPlugins.Core.pdb
  |__ DynamicPluginsDemoSite.deps.json
  |__ DynamicPluginsDemoSite.dll
  |__ DynamicPluginsDemoSite.pdb
  |__ DynamicPluginsDemoSite.runtimeconfig.dev.json
  |__ DynamicPluginsDemoSite.runtimeconfig.json
  |__ DynamicPluginsDemoSite.Views.dll
  |__ DynamicPluginsDemoSite.Views.pdb
  |__ Modules
    |__ DemoPlugin1
      |__ DemoPlugin1.dll
      |__ Views
        |__ Plugin1
          |__ HelloWorld.cshtml
        |__ _ViewStart.cshtml

如今咱们从新启动项目,从新按照以前的顺序,先激活插件,再访问新的插件路由/Modules/DemoPlugin1/plugin1/helloworld, 页面正常显示了。

总结

本篇中,我为你们演示了如何在运行时启用一个插件,这里咱们借助IActionDescriptorChangeProvider, 让ASP.NET Core在运行时从新加载了控制器,虽然不支持预编译Razor视图的加载,可是咱们经过配置原始Razor视图加载的目录规则,一样实现了动态读取视图的功能。

下一篇我将继续将这个项目重构,编写业务模型,并尝试编写插件的安装以及升降级版本的代码。

相关文章
相关标签/搜索