從零開始實現ASP.NET Core MVC的插件式開發(四) - 插件安裝
- 2019 年 10 月 3 日
- 筆記
標題:從零開始實現ASP.NET Core MVC的插件式開發(四) - 插件安裝
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/11343141.html
源程式碼:https://github.com/lamondlu/Mystique

前情回顧
- 從零開始實現ASP.NET Core MVC的插件式開發(一) - 使用Application Part動態載入控制器和視圖
- 從零開始實現ASP.NET Core MVC的插件式開發(二) - 如何創建項目模板
- 從零開始實現ASP.NET Core MVC的插件式開發(三) - 如何在運行時啟用組件
上一篇中,我們針對運行時啟用/禁用組件做了一些嘗試,最終我們發現藉助IActionDescriptorChangeProvider可以幫助我們實現所需的功能。本篇呢,我們就來繼續研究如何完成插件的安裝,畢竟之前的組件都是我們預先放到主程式中的,這樣並不是一種很好的安裝插件方式。
準備階段
創建資料庫
為了完成插件的安裝,我們首先需要為主程式創建一個資料庫,來保存插件資訊。 這裡為了簡化邏輯,我只創建了2個表,Plugins表是用來記錄插件資訊的,PluginMigrations表是用來記錄插件每個版本的升級和降級腳本的。

設計說明:這裡我的設計是將所有插件使用的資料庫表結構都安裝在主程式的資料庫中,暫時不考慮不同插件的資料庫表結構衝突,也不考慮插件升降級腳本的破壞性操作檢查,所以有類似問題的小夥伴可以先假設插件之間的表結構沒有衝突,插件遷移腳本中也不會包含破壞主程式所需系統表的問題。
備註:資料庫腳本可查看源程式碼的
DynamicPlugins.Database項目
創建一個安裝包
為了模擬安裝的效果,我決定將插件做成插件壓縮包,所以需要將之前的DemoPlugin1項目編譯後的文件以及一個plugin.json文件打包。安裝包的內容如下:
這裡暫時使用手動的方式來實現,後面我會創建一個Global Tools來完成這個操作。

在plugin.json文件中記錄當前插件的一些元資訊,例如插件名稱,版本等。
{ "name": "DemoPlugin1", "uniqueKey": "DemoPlugin1", "displayName":"Lamond Test Plugin1", "version": "1.0.0" } 編碼階段
在創建完插件安裝包,並完成資料庫準備操作之後,我們就可以開始編碼了。
抽象插件邏輯
為了項目擴展,我們需要針對當前業務進行一些抽象和建模。
創建插件介面和插件基類
首先我們需要將插件的概念抽象出來,所以這裡我們首先定義一個插件介面IModule以及一個通用的插件基類ModuleBase。
IModule.cs
public interface IModule { string Name { get; } DomainModel.Version Version { get; } } 在IModule介面中我們定義了當前插件的名稱和插件的版本號。
ModuleBase.cs
public class ModuleBase : IModule { public ModuleBase(string name) { Name = name; Version = "1.0.0"; } public ModuleBase(string name, string version) { Name = name; Version = version; } public ModuleBase(string name, Version version) { Name = name; Version = version; } public string Name { get; private set; } public Version Version { get; private set; } } ModuleBase類實現了IModule介面,並進行了一些初始化的操作。後續的插件類都需要繼承ModuleBase類。
解析插件配置
為了完成插件包的解析,這裡我創建了一個PluginPackage類,其中封裝了插件包的相關操作。
public class PluginPackage { private PluginConfiguration _pluginConfiguration = null; private Stream _zipStream = null; private string _folderName = string.Empty; public PluginConfiguration Configuration { get { return _pluginConfiguration; } } public PluginPackage(Stream stream) { _zipStream = stream; Initialize(stream); } public List<IMigration> GetAllMigrations(string connectionString) { var assembly = Assembly.LoadFile($"{_folderName}/{_pluginConfiguration.Name}.dll"); var dbHelper = new DbHelper(connectionString); var migrationTypes = assembly.ExportedTypes.Where(p => p.GetInterfaces().Contains(typeof(IMigration))); List<IMigration> migrations = new List<IMigration>(); foreach (var migrationType in migrationTypes) { var constructor = migrationType.GetConstructors().First(p => p.GetParameters().Count() == 1 && p.GetParameters()[0].ParameterType == typeof(DbHelper)); migrations.Add((IMigration)constructor.Invoke(new object[] { dbHelper })); } assembly = null; return migrations.OrderBy(p => p.Version).ToList(); } public void Initialize(Stream stream) { var tempFolderName = $"{ AppDomain.CurrentDomain.BaseDirectory }{ Guid.NewGuid().ToString()}"; ZipTool archive = new ZipTool(stream, ZipArchiveMode.Read); archive.ExtractToDirectory(tempFolderName); var folder = new DirectoryInfo(tempFolderName); var files = folder.GetFiles(); var configFiles = files.Where(p => p.Name == "plugin.json"); if (!configFiles.Any()) { throw new Exception("The plugin is missing the configuration file."); } else { using (var s = configFiles.First().OpenRead()) { LoadConfiguration(s); } } folder.Delete(true); _folderName = $"{AppDomain.CurrentDomain.BaseDirectory}Modules\{_pluginConfiguration.Name}"; if (Directory.Exists(_folderName)) { throw new Exception("The plugin has been existed."); } stream.Position = 0; archive.ExtractToDirectory(_folderName); } private void LoadConfiguration(Stream stream) { using (var sr = new StreamReader(stream)) { var content = sr.ReadToEnd(); _pluginConfiguration = JsonConvert.DeserializeObject<PluginConfiguration>(content); if (_pluginConfiguration == null) { throw new Exception("The configuration file is wrong format."); } } } } 程式碼解釋:
- 這裡在
Initialize方法中我使用了ZipTool類來進行解壓縮,解壓縮之後,程式會嘗試讀取臨時解壓目錄中的plugin.json文件,如果文件不存在,就會報出異常。 - 如果主程式中沒有當前插件,就會解壓到定義好的插件目錄中。(這裡暫時不考慮插件升級,下一篇中會做進一步說明)
GetAllMigrations方法的作用是從程式集中載入當前插件所有的遷移腳本。
新增腳本遷移功能
為了讓插件在安裝時,自動實現資料庫表的創建,這裡我還添加了一個腳本遷移機制,這個機制類似於EF的腳本遷移,以及之前分享過的FluentMigrator遷移。
這裡我們定義了一個遷移介面IMigration, 並在其中定義了2個介面方法MigrationUp和MigrationDown來完成插件升級和降級的功能。
public interface IMigration { DomainModel.Version Version { get; } void MigrationUp(Guid pluginId); void MigrationDown(Guid pluginId); } 然後我們實現了一個遷移腳本基類BaseMigration
public abstract class BaseMigration : IMigration { private Version _version = null; private DbHelper _dbHelper = null; public BaseMigration(DbHelper dbHelper, Version version) { this._version = version; this._dbHelper = dbHelper; } public Version Version { get { return _version; } } protected void SQL(string sql) { _dbHelper.ExecuteNonQuery(sql); } public abstract void MigrationDown(Guid pluginId); public abstract void MigrationUp(Guid pluginId); protected void RemoveMigrationScripts(Guid pluginId) { var sql = "DELETE PluginMigrations WHERE PluginId = @pluginId AND Version = @version"; _dbHelper.ExecuteNonQuery(sql, new List<SqlParameter> { new SqlParameter{ ParameterName = "@pluginId", SqlDbType = SqlDbType.UniqueIdentifier, Value = pluginId }, new SqlParameter{ ParameterName = "@version", SqlDbType = SqlDbType.NVarChar, Value = _version.VersionNumber } }.ToArray()); } protected void WriteMigrationScripts(Guid pluginId, string up, string down) { var sql = "INSERT INTO PluginMigrations(PluginMigrationId, PluginId, Version, Up, Down) VALUES(@pluginMigrationId, @pluginId, @version, @up, @down)"; _dbHelper.ExecuteNonQuery(sql, new List<SqlParameter> { new SqlParameter{ ParameterName = "@pluginMigrationId", SqlDbType = SqlDbType.UniqueIdentifier, Value = Guid.NewGuid() }, new SqlParameter{ ParameterName = "@pluginId", SqlDbType = SqlDbType.UniqueIdentifier, Value = pluginId }, new SqlParameter{ ParameterName = "@version", SqlDbType = SqlDbType.NVarChar, Value = _version.VersionNumber }, new SqlParameter{ ParameterName = "@up", SqlDbType = SqlDbType.NVarChar, Value = up}, new SqlParameter{ ParameterName = "@down", SqlDbType = SqlDbType.NVarChar, Value = down} }.ToArray()); } } 程式碼解釋
- 這裡的
WriteMigrationScripts和RemoveMigrationScripts的作用是用來將插件升級和降級的遷移腳本的保存到資料庫中。因為我並不想每一次都通過載入程式集的方式讀取遷移腳本,所以這裡在安裝插件時,我會將每個插件版本的遷移腳本導入到資料庫中。 SQL方法是用來運行遷移腳本的,這裡為了簡化程式碼,缺少了事務處理,有興趣的同學可以自行添加。
為之前的腳本添加遷移程式
這裡我們假設安裝DemoPlugin1插件1.0.0版本之後,需要在主程式的資料庫中添加一個名為
Test的表。
根據以上需求,我添加了一個初始的腳本遷移類Migration.1.0.0.cs, 它繼承了BaseMigration類。
public class Migration_1_0_0 : BaseMigration { private static DynamicPlugins.Core.DomainModel.Version _version = new DynamicPlugins.Core.DomainModel.Version("1.0.0"); private static string _upScripts = @"CREATE TABLE [dbo].[Test]( TestId[uniqueidentifier] NOT NULL, );"; private static string _downScripts = @"DROP TABLE [dbo].[Test]"; public Migration_1_0_0(DbHelper dbHelper) : base(dbHelper, _version) { } public DynamicPlugins.Core.DomainModel.Version Version { get { return _version; } } public override void MigrationDown(Guid pluginId) { SQL(_downScripts); base.RemoveMigrationScripts(pluginId); } public override void MigrationUp(Guid pluginId) { SQL(_upScripts); base.WriteMigrationScripts(pluginId, _upScripts, _downScripts); } } 程式碼解釋:
- 這裡我們通過實現
MigrationUp和MigrationDown方法來完成新表的創建和刪除,當然本文只實現了插件的安裝,並不涉及刪除或降級,這部分程式碼在後續文章中會被使用。 - 這裡注意在運行升級腳本之後,會將當前插件版本的升降級腳本通過
base.WriteMigrationScripts方法保存到資料庫。
添加安裝插件包的業務處理類
為了完成插件包的安裝邏輯,這裡我創建了一個PluginManager類, 其中AddPlugins方法使用來進行插件安裝的。
public void AddPlugins(PluginPackage pluginPackage) { var plugin = new DTOs.AddPluginDTO { Name = pluginPackage.Configuration.Name, DisplayName = pluginPackage.Configuration.DisplayName, PluginId = Guid.NewGuid(), UniqueKey = pluginPackage.Configuration.UniqueKey, Version = pluginPackage.Configuration.Version }; _unitOfWork.PluginRepository.AddPlugin(plugin); _unitOfWork.Commit(); var versions = pluginPackage.GetAllMigrations(_connectionString); foreach (var version in versions) { version.MigrationUp(plugin.PluginId); } } 程式碼解釋
- 方法簽名中的
pluginPackage即包含了插件包的所有資訊 - 這裡我們首先將插件的資訊,通過工作單元保存到了資料庫
- 保存成功之後,我通過
pluginPackage對象,獲取了當前插件包中所包含的所有遷移腳本,並依次運行這些腳本來完成資料庫的遷移。
在主站點中添加插件管理介面
這裡為了管理插件,我在主站點中創建了2個新頁面,插件列表頁以及添加新插件頁面。這2個頁面的功能非常的簡單,這裡我就不進一步介紹了,大部分的處理都是復用了之前的程式碼,例如插件的安裝,啟用和禁用,相關的程式碼大家可以自行查看。


設置已安裝插件默認啟動
在完成2個插件管理頁面之後,最後一步,我們還需要做的就是在注程式啟動階段,將已安裝的插件載入到運行時,並啟用。
public void ConfigureServices(IServiceCollection services) { ... var provider = services.BuildServiceProvider(); using (var scope = provider.CreateScope()) { var unitOfWork = scope.ServiceProvider.GetService<IUnitOfWork>(); var allEnabledPlugins = unitOfWork.PluginRepository.GetAllEnabledPlugins(); foreach (var plugin in allEnabledPlugins) { var moduleName = plugin.Name; var assembly = Assembly.LoadFile($"{AppDomain.CurrentDomain.BaseDirectory}Modules\{moduleName}\{moduleName}.dll"); var controllerAssemblyPart = new AssemblyPart(assembly); mvcBuilders.PartManager.ApplicationParts.Add(controllerAssemblyPart); } } } 設置完成之後,整個插件的安裝編碼就告一段落了。
最終效果

總結以及待解決的問題
本篇中,我給大家分享了如果將打包的插件安裝到系統中,並完成對應的腳本遷移。不過在本篇中,我們只完成了插件的安裝,針對插件的刪除,以及插件的升降級我們還未解決,有興趣的同學,可以自行嘗試一下,你會發現在.NET Core 2.2版本,我們沒有任何在運行時Unload程式集能力,所以在從下一篇開始,我將把當前項目的開發環境升級到.NET Core 3.0 Preview, 針對插件的刪除和升降級我將在.NET Core 3.0中給大家演示。