前言
为什么使用插件的方式去完成项目?并不是因为这很酷,仅仅是,我们希望降低代码的耦合度,让项目变得更容易维护,更容易拓展,彼此之间的依赖更清晰更简单。使用插件,我们可以在不影响现有功能情况下去开发新的功能,随意替换现有的功能模块,而不需要重新构建整个项目。看起来确实挺酷,这么酷的功能,在C#中实现起来却并不复杂。嗯,那就分享一下我在.NetCore 项目中使用插件的方式。
环境简介
系统环境
Windows 10 LTSC 2019
开发环境
SDK: .NetCore 2.2
Database:SQLite 3
开发工具
Editor:Visual Studio Code
Shell:Powershell
准备
项目准备
使用以下命令去创建以下项目。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| mkdir PluginDemo cd PluginDemo
dotnet new global.json --sdk-version 2.2.204
dotnet new web -o Host dotnet new classlib -o Share dotnet new classlib -o Plugin1
dotnet add .\Host\ reference .\Share\ dotnet add .\Plugin1\ reference .\Share\
code ./
|
按下F5进行调试,保证项目成功启动。
这里创建了三个项目,分别是Host、Plugin1、Share,依赖关系如下
Host <- Share
Plugin1 <- Share
数据库准备
在真正开始之前,最好部署一个可用的数据库,因为后面的Model映射会用到。
开始
准备好了吗?准备好那可就开始了。🚀
加载Controller
首先要做到的一点就是,把编写的程序集(其中包含继承自Controller的子类)丢到定义的插件目录,重启应用后,程序集中的Controller能够被成功路由。
废话不多说,用ApplicationPart来实现这一点。
修改Startup.cs*
项目 – Host
编辑Startup.cs 中的ConfigureServices方法中使用以下代码配置MVC
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| public void ConfigureServices(IServiceCollection services) { .....
services.AddMvc().ConfigureApplicationPartManager(manager => { string path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); var assmeblyFiles = Directory.GetFiles (path, "*.dll"); assmeblyFiles.Select(AssemblyLoadContext.Default.LoadFromAssemblyPath) .ToList().ForEach(assmebly => { if (assmebly.GetTypes().Any(typeof(Controller).IsAssignableFrom)) { AssemblyPart part = new AssemblyPart(assmebly); manager.ApplicationParts.Add(part); } });
});
..... }
... public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); }
app.UseMvc(); }
|
‘*.dll’ 可以用如 ‘*.plugin.dll’ 这样的字符串代替,取一个插件命名规则。最好把这个规则放在配置文件中。
接下来编写一个Controller进行测试。
项目 – Plugin1
添加PluginController.cs
!PluginController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| using Microsoft.AspNetCore.Mvc;
namespace Plugin1 { [ApiController] [Route ("api/[controller]/[action]")] public class PluginController : Controller { public PluginController() {
}
public string Ping() => "pong";
} }
|
编写完之后还需要添加MVC的NuGet包。
1
| dotnet add .\Plugin1\ package Microsoft.AspNetCore.Mvc
|
完成NuGet包添加后就可以构建Plugin1项目了。
编写tasks.json
等等,如果不想每次都dotnet build .\Plugin1\,还需要配置下tasks.json
第一次F5调试的时候会配置构建任务生成该文件。
tasks.json中默认添加了三个任务build,publish,watch,现在添加Plugin1的构建任务。
编辑tasks.json 加入以下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| { "label": "build plugin1", "command": "dotnet", "type": "process", "args": [ "build", "${workspaceFolder}/Plugin1/Plugin1.csproj" ], "problemMatcher": "$tsc" }, { "label": "build & install plugin1", "command": "cp", "type": "shell", "args": [ "${workspaceFolder}/Plugin1/bin/Debug/netstandard2.0/Plugin1.dll", "${workspaceFolder}/Host/bin/Debug/netcoreapp2.2/Plugin1.dll" ], "windows": { "command": "xcopy", "args": [ "/y", "${workspaceFolder}\\Plugin1\\bin\\Debug\\netstandard2.0\\Plugin1.dll", "${workspaceFolder}\\Host\\bin\\Debug\\netcoreapp2.2\\Plugin1.dll" ] }, "dependsOn": [ "build plugin1" ], "problemMatcher": "$tsc" }
|
在VS Code中执行任务build & install plugin1
ctrl+shift+p 输入 run task 选择 build & install plugin1
注意,第一次运行命令可能会询问是复制的文件还是文件夹,在任务终端中,输入F确定是文件即可。
执行成功后在Host的bin/Debug/netcoreapp2.2目录下瞄一眼有没有Plugin1.dll,有的话就👌。
启动
F5启动项目,然后在浏览器输入 https://5001/api/plugin/ping。
当看到浏览器回复了一个’pong’ 之后,意味着,成功了😀。
注入Services
Plugin1.dll 除了需要响应请求之外,它还需要使用IOC容器进行DI。.NetCore 已经内置了IOC容器,需要解决的问题就是:怎么把这个容器传递给插件🤔。这里使用反射来完成它。
思路是这样,定义一个用来注入服务的接口。在Host项目中,需要遍历所有实现了该接口的程序集并调用注入方法,然后在插件项目中实现该接口。当然,这个接口需要放在Share项目中。
用来注入的接口*
项目 – Share
在VS Code 中新建IServicesRegister.cs
!IServicesRegister.cs
1 2 3 4 5 6 7 8 9
| using Microsoft.Extensions.DependencyInjection;
namespace Share { public interface IServicesRegister { void RegisterServices(IServiceCollection services); } }
|
这里还需要添加Microsoft.Extensions.DependencyInjection NuGet 包。
1
| dotnet add .\Share\ package Microsoft.Extensions.DependencyInjection
|
命令是在*PluginDemo 路径下执行的,可不要混了。*
这个接口极其简单,只有一个RegisterServices的方法,我这里保证这个接口有且只有这一个方法,方便待会能找到它。
完成注入*
现在在Startup.cs中添加一个RegisterServices方法(名字是和接口的重复的,这没关系,不要混淆了即可),或者可以直接ConfigureServices 中操作。
!Startup.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| ... public void ConfigureServices(IServiceCollection services) { ... RegisterServices(services); ... }
public void RegisterServices(IServiceCollection services) { var dllFiles = Directory.GetFiles(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "*.dll"); var assemblies = dllFiles.Select(AssemblyLoadContext.Default.LoadFromAssemblyPath).ToList(); assemblies.ForEach(ass => { var registers = ass.GetTypes().Where(m => m.IsClass && typeof(IServicesRegister).IsAssignableFrom(m)).ToList(); registers.ForEach(reg => { var instance = Activator.CreateInstance(reg); var method = typeof(IServicesRegister).GetMethods().SingleOrDefault();
method.Invoke(instance, new object[] { services }); }); }); }
...
|
编写Service进行测试
项目 – Plugin1
需要添加三个文件
- ServicesRegister.cs
- IAddService.cs
- AddService.cs
ServicesRegister.cs 用来实现注入,AddService.cs 将实现 IAddService 接口用来测试。
!ServicesRegister.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| using System.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Share;
namespace Plugin1 { public class ServicesRegister : IServicesRegister { public void RegisterServices(IServiceCollection services) { Debug.WriteLine("Register plugin1's services", "info: ");
services.AddTransient<IAddService,AddService>();
} } }
|
!IAddService.cs
1 2 3 4 5 6 7
| namespace Plugin1 { public interface IAddService { string Add(string a, string b); } }
|
!AddService.cs
1 2 3 4 5 6 7
| namespace Plugin1 { public class AddService : IAddService { public string Add(string a, string b) => a + b; } }
|
最后修改PluginController.cs 添加AddService的注入依赖和测试方法。
!PluginController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| using Microsoft.AspNetCore.Mvc;
namespace Plugin1 { [ApiController] [Route("api/[controller]/[action]")] public class PluginController : Controller {
readonly IAddService m_addService; public PluginController(IAddService addService) { m_addService = addService; }
public string Ping() => "pong"; public string PingService(string a, string b) => m_addService.Add(a, b); } }
|
启动项目
先执行 build & install plugin1命令,然后F5启动。🚀
一切完成,在浏览器地址栏输入 https://localhost:5001/api/plugin/pingservice?a=service&b=done。
看看浏览器响应,是否把’service’ 和 ‘done’ 拼到一起了呢。
再去调试控制台看打印的Debug.WriteLine("Register plugin1's services", "info: "); 有没有正确输出。👏👏👏
独立配置文件
当决定让插件成为一个完整独立的功能模块时,插件应该使用独立的配置文件,配置过程和普通的项目配置没有太大区别,这里就简单讲一下。
项目 – Plugin1
添加以下两个文件
- plugin1.json
- Plugin1Config.cs
ServicesRegister.cs中配Config*
修改ServicesRegister.cs 中 方法 RegisterServices 如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class ServicesRegister : IServicesRegister { public void RegisterServices(IServiceCollection services) { Debug.WriteLine("Register plugin1's services", "info: "); var config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("plugin1.json", false) .Build();
services.Configure<Plugin1Config>(config.GetSection(nameof(Plugin1Config)));
services.AddTransient<IAddService, AddService>();
} }
|
Plugin1Config.cs
!Plugin1Config.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| namespace Plugin1 { public class Plugin1Config { public string Name { get; set; } public string Version { get; set; }
public string Author { get; set; } } }
|
添加配置文件
!plugin1.json
1 2 3 4 5 6 7
| { "Plugin1Config":{ "Name":"Plugin1", "Version":"1.1", "Author":"Your Name" } }
|
想要成功构建Plugin1项目还需要添加以下NuGet包
- Microsoft.Extensions.Configuration
- Microsoft.Extensions.Configuration.FileExtensions
- Microsoft.Extensions.Configuration.Json
- Microsoft.Extensions.Options.ConfigurationExtensions
1 2 3 4
| dotnet add .\Plugin1\ package Microsoft.Extensions.Configuration dotnet add .\Plugin1\ package Microsoft.Extensions.Configuration.FileExtensions dotnet add .\Plugin1\ package Microsoft.Extensions.Configuration.Json dotnet add .\Plugin1\ package Microsoft.Extensions.Options.ConfigurationExtensions
|
项目 – Host
另外 ,Host 中还需要在Startup.cs文件 ConfigureServices 方法中加上一行
测试配置
emmm,Controller的代码看起来是这样。
!PluginController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options;
namespace Plugin1 { [ApiController] [Route("api/[controller]/[action]")] public class PluginController : Controller {
readonly IAddService m_addService; readonly Plugin1Config m_config; public PluginController(IAddService addService, IOptions<Plugin1Config> options) { m_addService = addService; m_config = options.Value; }
public string Ping() => "pong"; public string PingService(string a, string b) => m_addService.Add(a, b);
public ActionResult<Plugin1Config> PingConfig() => m_config; } }
|
添加NuGet引用
1
| dotnet add .\Plugin1\ package Microsoft.Extensions.Options
|
再次启动
现在像之前那样构建项目,在启动项目之前,把Plugin1的配置文件plugin1.json拷贝到Host目录下。
1 2 3 4 5
| var config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("plugin1.json", false) .Build();
|
启动之后,在浏览器地址栏输入https://localhost:5001/api/plugin/pingconfig。
看看有没有正常读取到配置。🙂
映射Model
在插件中完成某些拓展功能时,可能需要数据库的表结构支持,但是,我不想把插件中的Model放到Share项目中,插件应该自己管理自己的Model。(究竟是放在Share中还是放在插件项目中,具体需要看插件的定位)
需要准备一个数据库环境,我这里使用SQLite。
数据库表结构
表名 t_plugdata
任意加一些数据进去,比如
| Id |
Name |
Data |
| 1 |
Ok |
true |
| 2 |
Pity |
I am ok |
| 3 |
Lisa |
Hi,I am here |
保证有点数据就好了,有关数据库链接字符串或者其他涉及的东西不在这里细讲。
配置数据库上下文*
项目 – Share
添加HostDbContext.cs
!HostDbContext.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| using System; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.Loader; using Microsoft.EntityFrameworkCore;
namespace Share { public class HostDbContext : DbContext { public HostDbContext(DbContextOptions options) : base(options) {
}
protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder);
var dllfiles = Directory.GetFiles(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "*.dll"); Array.ForEach(dllfiles, files => { var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(files); assembly.GetTypes().Where(type => type.GetInterface(typeof(IEntityTypeConfiguration<>).FullName) != null) .ToList().ForEach(type => { dynamic instance = Activator.CreateInstance(type); modelBuilder.ApplyConfiguration(instance); }); }); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.UseSqlite("Data Source=./HostDb.db"); } } } }
|
构建Share项目可能用到的NuGet 包
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.SQLite
- System.Runtime.Loader
- System.Linq
1 2 3 4
| dotnet add .\Share\ package Microsoft.EntityFrameworkCore dotnet add .\Share\ package Microsoft.EntityFrameworkCore.SQLite dotnet add .\Share\ package System.Runtime.Loader dotnet add .\Share\ package System.Linq
|
映射Model 并实现 IEntiyTypeConfigration*
项目 – Plugin1
添加文件 PluginData.cs
!PluginData.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Plugin1 { [Table("t_plugdata")] public class PluginData { public string Id { get; set; }
public string Name { get; set; }
public string Data { get; set; }
}
public class PluginDataConfig : IEntityTypeConfiguration<PluginData> { public void Configure(EntityTypeBuilder<PluginData> builder) { builder.ToTable("t_plugdata"); } } }
|
构建Plugin1 需要添加的NuGet 包
1
| dotnet add .\Plugin1\ package Microsoft.EntityFrameworkCore
|
注入数据库上下文
项目 – Host
Host 中还需要在Startup.cs文件 ConfigureServices 方法中加上一行
1
| services.AddDbContext<HostDbContext>();
|
测试数据库上下文
在PluginController.cs中进行注入和测试。
!PluginController.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Share;
namespace Plugin1 { [ApiController] [Route("api/[controller]/[action]")] public class PluginController : Controller {
readonly IAddService m_addService; readonly Plugin1Config m_config; readonly HostDbContext m_dbContext;
public PluginController(IAddService addService, IOptions<Plugin1Config> options, HostDbContext dbContext) { m_addService = addService; m_config = options.Value; m_dbContext = dbContext; }
public string Ping() => "pong"; public string PingService(string a, string b) => m_addService.Add(a, b);
public ActionResult<Plugin1Config> PingConfig() => Json(m_config); public ActionResult<List<PluginData>> PingDb() => m_dbContext.Set<PluginData>().ToList(); } }
|
可能需要添加的NuGet包
1
| dotnet add .\Plugin1\ package System.Linq
|
可以说是终于完成最后一步了。
最后一步?我还想补充一点。
PluginController PingDb 方法中用m_dbContext.Set<PluginData>()这种方法使用HostDbContext,为了方便使用,可以写一个HostDbContext的拓展类。大概像这个样子
!HostDbContextExtensions.cs
1 2 3 4 5 6 7 8 9 10 11
| using Microsoft.EntityFrameworkCore; using Share;
namespace Plugin1 { public static class HostDbContextExtensitions { public static DbSet<PluginData> PluginDatas(this HostDbContext context) => context.Set<PluginData>();
} }
|
emmm,这次是完结了。
又又启动
构建插件,然后启动项目。
在浏览器地址栏输入https://localhost:5001/api/plugin/pingdb。
怎么样,有结果了吗?👀
最后
我的个人观点。
我觉得可以从两个方向去使用插件
- Host项目功能完备,插件只是拓展功能。
- Host项目完全依赖插件实现功能
混合使用这两种方式也好,单单只用其中一个方式也好。都没问题,具体取决于手中的键盘,不是么。
还有一点,.NetCore 中已经没有AppDomain的概念了,插件无法实现热插拔。
完结👌。
2020年7月14日 更
这个插件实现方式对 .netcore 2.2 还是可以的,对现在来讲,有些过时。