• 从零开始实现ASP.NET Core MVC的插件式开发(三)


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

    前情回顾

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

    当你添加一个插件之后,整个程序不能立刻启用该插件,只有当重启整个ASP.NET Core应用之后,才能正确的加载插件。因为所有插件的加载都是在程序启动时ConfigureService方法中配置的。

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

    为了完成这个功能,我走了许多弯路,当前这个方案可能不是最好的,但是确实是一个可行的方案,如果大家有更好的方案,我们可以一起讨论一下。

    在Action中激活组件

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

    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/HelloWorld

    这里会发现程序返回了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.CoreDynamicPlugins.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视图加载的目录规则,同样实现了动态读取视图的功能。

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

  • 相关阅读:
    暑 假 队 测 Round #5
    暑 假 队 测 Round #4
    暑 假 队 测 Round #3
    什么是nginx、CGI、fastCGI、php-fpm、PHP-CGI,fast-CGI 与 php-cgi又是什么关系
    Go源码各目录含义
    User space 与 Kernel space
    Shell脚本编程30分钟入门
    Tomcat7配置及其servlet调用详解
    最简单的移动端适配方案
    textarea元素只设置高可变,宽固定
  • 原文地址:https://www.cnblogs.com/lwqlun/p/11260750.html
Copyright © 2020-2023  润新知