從零開始實現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.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視圖加載的目錄規則,同樣實現了動態讀取視圖的功能。

下一篇我將繼續將這個項目重構,編寫業務模型,並嘗試編寫插件的安裝以及升降級版本的代碼。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM