标题:从零开始实现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中给大家演示。