標題:從零開始實現ASP.NET Core MVC的外掛式開發(三) - 如何在執行時啟用元件
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/11260750.html
原始碼:https://github.com/lamondlu/DynamicPlugins
前情回顧回顧
- 從零開始實現ASP.NET Core MVC的外掛式開發(一) - 使用Application Part動態載入控制器和檢視
- 從零開始實現ASP.NET Core MVC的外掛式開發(二) - 如何建立專案模板
在前面兩篇中,我為大家演示瞭如何使用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.cs
的ConfigureServices
方法中,將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.cs
的ConfigureServices
方法中配置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檢視載入的目錄規則,同樣實現了動態讀取檢視的功能。
下一篇我將繼續將這個專案重構,編寫業務模型,並嘗試編寫外掛的安裝以及升降級版本的程式碼。