從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝

LamondLu發表於2019-08-12

標題:從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝
作者:Lamond Lu
地址:https://www.cnblogs.com/lwqlun/p/11343141.html
原始碼:https://github.com/lamondlu/DynamicPlugins

從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝

前情回顧

上一篇中,我們針對執行時啟用/禁用元件做了一些嘗試,最終我們發現藉助IActionDescriptorChangeProvider可以幫助我們實現所需的功能。本篇呢,我們就來繼續研究如何完成外掛的安裝,畢竟之前的元件都是我們預先放到主程式中的,這樣並不是一種很好的安裝外掛方式。

準備階段

建立資料庫

為了完成外掛的安裝,我們首先需要為主程式建立一個資料庫,來儲存外掛資訊。 這裡為了簡化邏輯,我只建立了2個表,Plugins表是用來記錄外掛資訊的,PluginMigrations表是用來記錄外掛每個版本的升級和降級指令碼的。

從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝

設計說明:這裡我的設計是將所有外掛使用的資料庫表結構都安裝在主程式的資料庫中,暫時不考慮不同外掛的資料庫表結構衝突,也不考慮外掛升降級指令碼的破壞性操作檢查,所以有類似問題的小夥伴可以先假設外掛之間的表結構沒有衝突,外掛遷移指令碼中也不會包含破壞主程式所需系統表的問題。

備註:資料庫指令碼可檢視原始碼的DynamicPlugins.Database專案

建立一個安裝包

為了模擬安裝的效果,我決定將外掛做成外掛壓縮包,所以需要將之前的DemoPlugin1專案編譯後的檔案以及一個plugin.json檔案打包。安裝包的內容如下:

這裡暫時使用手動的方式來實現,後面我會建立一個Global Tools來完成這個操作。

從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝

在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個介面方法MigrationUpMigrationDown來完成外掛升級和降級的功能。

    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());
        }
    }

程式碼解釋

  • 這裡的WriteMigrationScriptsRemoveMigrationScripts的作用是用來將外掛升級和降級的遷移指令碼的儲存到資料庫中。因為我並不想每一次都通過載入程式集的方式讀取遷移指令碼,所以這裡在安裝外掛時,我會將每個外掛版本的遷移指令碼匯入到資料庫中。
  • 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);
        }
    }

程式碼解釋

  • 這裡我們通過實現MigrationUpMigrationDown方法來完成新表的建立和刪除,當然本文只實現了外掛的安裝,並不涉及刪除或降級,這部分程式碼在後續文章中會被使用。
  • 這裡注意在執行升級指令碼之後,會將當前外掛版本的升降級指令碼通過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個頁面的功能非常的簡單,這裡我就不進一步介紹了,大部分的處理都是複用了之前的程式碼,例如外掛的安裝,啟用和禁用,相關的程式碼大家可以自行檢視。

從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝
從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝

設定已安裝外掛預設啟動

在完成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);
            }
        }   
    }

設定完成之後,整個外掛的安裝編碼就告一段落了。

最終效果

從零開始實現ASP.NET Core MVC的外掛式開發(四) - 外掛安裝

總結以及待解決的問題

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

相關文章