44. 插件开发与动态编译
一句话定位:Admin.NET 的插件分两种形态——编译好的 DLL 插件(放 plugins 目录,由 ExternalAssemblies 机制自动装载)与在线脚本插件(SysPluginService 把一段 C# 源码即时编译成程序集并注册为动态 API,无需重启)。
一、ExternalAssemblies 机制
App.json
"AppSettings": { "ExternalAssemblies": [ "plugins" ] }
│
启动时 Furion 扫描运行目录下的 plugins 文件夹
│
加载 *.dll → 加入应用部件(ApplicationPart)与 DI 扫描
│
├─ [AppStartup(n)] Startup 类的 ConfigureServices/Configure 被执行
├─ IDynamicApiController 服务自动生成 WebAPI 路由
├─ IScoped / ISingleton / ITransient 标记类自动注册
└─ 实体上的 [SugarTable] 参与 CodeFirst 建表配置位置有两个版本(主文件 vs App.Development.json):
// Admin.NET.Application/Configuration/App.json
"AppSettings": {
"InjectSpecificationDocument": false, // 生产环境是否开启 Swagger
"ExternalAssemblies": [ "plugins" ], // ← 插件目录
"VirtualPath": "" // 二级虚拟目录
}两条落地路径:
| 方式 | 做法 | 适用 |
|---|---|---|
| 强引用 | Admin.NET.Application.csproj 里加 <ProjectReference Include="..\Plugins\Xxx\Xxx.csproj" /> | 稳定、常用插件(框架自带的 Ai / PaddleOCR / WorkFlow / WorkWeixin / Document 就是这种) |
| 松耦合 | 把插件 DLL 丢进运行目录的 plugins/ 文件夹,重启后由 ExternalAssemblies 装载 | 客户按需投放、第三方闭源模块、商业插件分发 |
框架自带的强引用清单(Admin.NET.Application/Admin.NET.Application.csproj):
<ItemGroup>
<ProjectReference Include="..\Admin.NET.Core\Admin.NET.Core.csproj" />
<ProjectReference Include="..\Plugins\Admin.NET.Plugin.Ai\Admin.NET.Plugin.Ai.csproj" />
<ProjectReference Include="..\Plugins\Admin.NET.Plugin.PaddleOCR\Admin.NET.Plugin.PaddleOCR.csproj" />
<ProjectReference Include="..\Plugins\Admin.NET.Plugin.WorkFlow\Admin.NET.Plugin.WorkFlow.csproj" />
<ProjectReference Include="..\Plugins\Admin.NET.Plugin.WorkWeixin\Admin.NET.Plugin.WorkWeixin.csproj" />
<ProjectReference Include="..\Plugins\Admin.NET.Plugin.Document\Admin.NET.Plugin.Document.csproj" />
</ItemGroup>其余(DingTalk / GoView / K3Cloud / Media / ReZero / ViewFace / DataApproval / WorkflowCore.Persistence.MySQL)在 Admin.NET.sln 的 Plugins 解决方案文件夹里,可按需加入强引用或打成 DLL 丢 plugins/。
二、新建插件工程步骤
① 建目录 Plugins/Admin.NET.Plugin.Demo
② csproj(照抄框架模板)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<NoWarn>1701;1702;1591;8632</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<Copyright>Admin.NET</Copyright>
<Description>Admin.NET 通用权限开发平台</Description>
</PropertyGroup>
<ItemGroup>
<None Update="Configuration\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Admin.NET.Core\Admin.NET.Core.csproj" />
</ItemGroup>
</Project>③ 可选 Startup.cs(要做 DI 注册时写)
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace Admin.NET.Plugin.Demo;
[AppStartup(100)] // 数字越大越晚执行
public class Startup : AppStartup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddConfigurableOptions<DemoOptions>();
services.AddHttpRemote(builder => builder.AddHttpDeclarative<IDemoApi>());
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { }
}自己的配置选项记得同步登记,否则读不到:
// Admin.NET.Web.Core/ProjectOptions.cs
services.AddConfigurableOptions<DemoOptions>();不过更推荐在插件自己的 Startup.ConfigureServices 里 AddConfigurableOptions<T>()(Admin.NET.Plugin.DingTalk 就是这么做的),宿主零改动。
④ 实体 / 服务
// Entity/DemoItem.cs
using Admin.NET.Core;
using SqlSugar;
namespace Admin.NET.Plugin.Demo;
[SugarTable(null, "演示条目表")]
public class DemoItem : EntityTenantBaseData
{
[SugarColumn(ColumnDescription = "标题", Length = 128)]
public string Title { get; set; }
[SugarColumn(ColumnDescription = "数量")]
public int Qty { get; set; }
}// Service/DemoService.cs
namespace Admin.NET.Plugin.Demo;
/// <summary>
/// 演示服务 🧩
/// </summary>
[ApiDescriptionSettings("Demo", Order = 100)]
public class DemoService : IDynamicApiController, IScoped
{
private readonly SqlSugarRepository<DemoItem> _demoRep;
public DemoService(SqlSugarRepository<DemoItem> demoRep) => _demoRep = demoRep;
/// <summary>
/// 获取演示条目分页 🔖
/// </summary>
[DisplayName("获取演示条目分页")]
public async Task<SqlSugarPagedList<DemoItem>> Page(DemoPageInput input)
{
return await _demoRep.AsQueryable()
.WhereIF(!string.IsNullOrWhiteSpace(input.Title), u => u.Title.Contains(input.Title!))
.ToPagedListAsync(input.Page, input.PageSize);
}
}⑤ 配置文件(可选)
// Configuration/Demo.json
{
"$schema": "https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json",
"Demo": {
"BaseUrl": "https://api.demo.com",
"Timeout": 30
}
}⑥ 菜单与授权
插件里的服务同样会被 SysCommonService.GetAllSysApiList() 扫描到,因此照样要在 系统管理 → 菜单管理 里登记权限码(参见第 14 篇)。
三、SysPluginService 在线编译 C# 注册 API
源码:Admin.NET.Core/Service/Plugin/SysPluginService.cs([ApiDescriptionSettings(Order = 245, Description = "动态插件")])
核心方法
public class SysPluginService : IDynamicApiController, ITransient
{
private readonly IDynamicApiRuntimeChangeProvider _provider;
private readonly SqlSugarRepository<SysPlugin> _sysPluginRep;
/// <summary>获取动态插件列表 🧩</summary>
[DisplayName("获取动态插件列表")]
public async Task<SqlSugarPagedList<SysPlugin>> Page(PagePluginInput input) { ... }
/// <summary>增加动态插件 🧩</summary>
[ApiDescriptionSettings(Name = "Add"), HttpPost]
[DisplayName("增加动态插件")]
public async Task AddPlugin(AddPluginInput input)
{
var isExist = await _sysPluginRep.IsAnyAsync(u => u.Name == input.Name || u.AssemblyName == input.AssemblyName);
if (isExist) throw Oops.Oh(ErrorCodeEnum.D1900); // 已存在同名功能或同名程序及插件
input.AssemblyName = CompileAssembly(input.CsharpCode, input.AssemblyName);
await _sysPluginRep.InsertAsync(input.Adapt<SysPlugin>());
}
/// <summary>更新动态插件 🧩</summary>
[ApiDescriptionSettings(Name = "Update"), HttpPost]
[DisplayName("更新动态插件")]
public async Task UpdatePlugin(UpdatePluginInput input)
{
var isExist = await _sysPluginRep.IsAnyAsync(u => (u.Name == input.Name || u.AssemblyName == input.AssemblyName) && u.Id != input.Id);
if (isExist) throw Oops.Oh(ErrorCodeEnum.D1900);
RemoveAssembly(input.AssemblyName); // 先移除再添加
input.AssemblyName = CompileAssembly(input.CsharpCode);
await _sysPluginRep.AsUpdateable(input.Adapt<SysPlugin>()).IgnoreColumns(true).ExecuteCommandAsync();
}
/// <summary>删除动态插件 🧩</summary>
[ApiDescriptionSettings(Name = "Delete"), HttpPost]
[DisplayName("删除动态插件")]
public async Task DeletePlugin(DeletePluginInput input)
{
var plugin = await _sysPluginRep.GetByIdAsync(input.Id);
if (plugin == null) return;
RemoveAssembly(plugin.AssemblyName);
await _sysPluginRep.DeleteByIdAsync(input.Id);
}
/// <summary>添加动态程序集/接口 🧩</summary>
[DisplayName("添加动态程序集/接口")]
public string CompileAssembly([FromBody] string csharpCode, [FromQuery] string assemblyName = default)
{
using var dynamicAssembly = App.CompileCSharpClassCode(csharpCode, assemblyName);
_provider.AddAssembliesWithNotifyChanges(dynamicAssembly.Assembly); // 通知动态 WebAPI 刷新
return dynamicAssembly.Assembly.GetName().Name;
}
/// <summary>移除动态程序集/接口 🧩</summary>
[ApiDescriptionSettings(Name = "RemoveAssembly"), HttpPost]
[DisplayName("移除动态程序集/接口")]
public void RemoveAssembly(string assemblyName)
=> _provider.RemoveAssembliesWithNotifyChanges(assemblyName);
/// <summary>加载所有动态程序集/接口(启动时调用)</summary>
[NonAction]
public async Task LoadAllAssembly()
{
foreach (var plugin in await _sysPluginRep.GetListAsync())
CompileAssembly(plugin.CsharpCode, plugin.AssemblyName);
}
}数据表 SysPlugin
[SugarTable(null, "系统动态插件表")]
[SysTable]
[SugarIndex("i_{table}_n", nameof(Name), OrderByType.Asc)]
public partial class SysPlugin : EntityTenant
{
public virtual string Name { get; set; } // 名称(唯一)
public virtual string CsharpCode { get; set; } // C# 源码(BigString)
public string? AssemblyName { get; set; } // 编译后程序集名(512)
public int OrderNo { get; set; } = 100; // 排序
public StatusEnum Status { get; set; } = StatusEnum.Enable;
public string? Remark { get; set; }
}DTO:PagePluginInput : BasePageInput、AddPluginInput : SysPlugin、UpdatePluginInput : AddPluginInput、DeletePluginInput : BaseIdInput(Admin.NET.Core/Service/Plugin/Dto/PluginInput.cs)。
脚本插件里能写什么
// 在「系统管理 → 动态插件 → 新增」界面填的 C# 代码,示例:
public class HelloPluginService : IDynamicApiController, IScoped
{
private readonly ISqlSugarClient _db;
public HelloPluginService(ISqlSugarClient db) => _db = db;
[DisplayName("打个招呼")]
public string SayHello(string name) => $"Hello, {name}! 现在是 {DateTime.Now:yyyy-MM-dd HH:mm:ss}";
}约束(来自 App.CompileCSharpClassCode 的实现语义):
- 必须是完整可编译的 C# 源码,引用的程序集必须已加载进 AppDomain(建议只依赖
Admin.NET.Core与 BCL) - 类名不要与现有类重名(否则
AddAssembliesWithNotifyChanges后路由冲突) - 不要在脚本里定义新实体 + 期望自动建表(建表走 CodeFirst 需要原生工程)
- 服务注入请用接口/现有服务;
App.xxx静态访问(如App.GetRequiredService<T>())也可以用
重启后如何恢复
LoadAllAssembly() 是 [NonAction] 的,需要在启动钩子处调用。框架把它挂在 Admin.NET.Core/Job/StartHostedService.cs(services.AddHostedService<StartHostedService>()),如果你二开了启动逻辑,请确认这行还在。
四、应用市场插件清单
当前 solution 中 Plugins/ 目录下的插件(均含 bin/obj,可独立编译):
| 插件 | 目录 | 用途 |
|---|---|---|
| Ai | Plugins/Admin.NET.Plugin.Ai | AI / 大模型能力 ✅已强引用 |
| PaddleOCR | Plugins/Admin.NET.Plugin.PaddleOCR | OCR 识别 ✅已强引用 |
| WorkFlow | Plugins/Admin.NET.Plugin.WorkFlow | 工作流(WorkflowCore) ✅已强引用 |
| WorkWeixin | Plugins/Admin.NET.Plugin.WorkWeixin | 企业微信集成 ✅已强引用 |
| Document | Plugins/Admin.NET.Plugin.Document | 文档管理 ✅已强引用 |
| DingTalk | Plugins/Admin.NET.Plugin.DingTalk | 钉钉集成 |
| GoView | Plugins/Admin.NET.Plugin.GoView | 大屏设计器数据接口 |
| K3Cloud | Plugins/Admin.NET.Plugin.K3Cloud | 金蝶云星空对接 |
| Media | Plugins/Admin.NET.Plugin.Media | 音视频/流媒体 |
| ReZero | Plugins/Admin.NET.Plugin.ReZero | ReZero 动态接口引擎 |
| ViewFace | Plugins/Admin.NET.Plugin.ViewFace | 人脸识别(SeetaFace/虹软内核库) |
| DataApproval | Plugins/Admin.NET.Plugin.DataApproval | 数据审批 |
| WorkflowCore.Persistence.MySQL | Plugins/WorkflowCore.Persistence.MySQL | 工作流 MySQL 持久化 |
标记为"未强引用"的插件需要你自己决定是否加入
Admin.NET.Application.csproj,或编译后丢plugins/目录。
五、步骤清单
A. 编译型 DLL 插件
Plugins/下新建类库工程(csproj 照抄模板,TargetFrameworks与主工程保持一致)- 引用
..\..\Admin.NET.Core\Admin.NET.Core.csproj - 写
Entity/Service/ 可选Startup.cs [AppStartup(n)] - 方式一:
Admin.NET.Application.csproj加ProjectReference,重新生成 - 方式二:生成 DLL 后拷到宿主运行目录的
plugins/文件夹,重启 - 系统管理 → 菜单管理 登记权限码,角色授权后刷新接口缓存
- 打开
/kapi确认接口已出现
B. 在线脚本插件
- 系统管理 → 动态插件 → 新增
- 填 名称 / C#代码 / 排序 / 状态
- 保存即触发
CompileAssembly→ 接口立刻可用(无需重启) - 修改走"更新"(先 RemoveAssembly 再重新编译)
- 确认
StartHostedService会在下一次启动时LoadAllAssembly()
⚠️ 注意事项 / 常见坑
plugins目录是相对于运行目录的:发布后路径是<ContentRoot>/plugins。Docker 部署要把目录挂进去或 COPY 进镜像,否则插件静默不生效(没有任何报错)。- 目标框架必须一致:主工程
net8.0;net10.0,插件也必须<TargetFrameworks>net8.0;net10.0</TargetFrameworks>,少一个会在切换目标框架时加载失败。 [AppStartup(n)]的 n 决定执行顺序,值越大越晚。依赖宿主已注册配置时用较大的值(如 100+)。- 实体要能被 CodeFirst 扫到,必须贴
[SugarTable];插件实体若不想建表就别贴[SysTable](参见第 13 篇的表结构初始化规则)。 - 脚本插件扭改 And 重启:
CompileAssembly编译出的程序集只在内存中,重启后靠LoadAllAssembly()从SysPlugin.CsharpCode重新编译恢复。数据库里的源码一旦被删,接口就永久没了。 RemoveAssembly(assemblyName)按名字匹配:手动把库里的AssemblyName改错会导致删不掉旧路由,出现"接口还在但类已不在"的诡异状态。- 同名判定同时看
Name和AssemblyName:AddPlugin/UpdatePlugin的IsAny用||判断两者,重名报D1900。 using var dynamicAssembly = ...:CompileCSharpClassCode返回的是可释放对象,using释放的是编译上下文(不是卸载程序集),别以为它释放了运行时程序集——.NET 中单个程序集无法从 AppDomain 卸载,反复更新会累积元数据。- 脚本插件不适合写复杂导航属性:
[Navigate]/ 复杂制图需要 Mapper 配置,建议简化为手写 Join。 Configuration/*.json必须设为PreserveNewest(模板里已配),否则设定值拷不到输出目录,运行时读出来全是默认值。- 插件里的服务也要授权:别忘了它是公开 WebAPI,没登记权限码=所有人可访问(第 14 篇)。
- 原生依赖库(如
libSeetaMaskDetector200.so、skiaDLL)不会被ExternalAssemblies自动探测,必须随 DLL 一起放到plugins/或系统库路径。 - 卸载不等于下线:
RemoveAssembly只移除动态 API 部件,实体类、单例服务、已注册的[JobDetail]仍然留在进程里。
小结
AppSettings.ExternalAssemblies: ["plugins"]是 DLL 插件的总开关,运行时扫描运行目录下的plugins/- 强引用(加
ProjectReference)与松耦合(丢 DLL)二选一,框架自带的 Ai/PaddleOCR/WorkFlow/WorkWeixin/Document 走前者 - 插件三件套:
Entity(贴[SugarTable])+Service(IDynamicApiController)+ 可选Startup([AppStartup(n)]) SysPluginService提供CompileAssembly/RemoveAssembly/LoadAllAssembly,把 C# 源码即时编译成 WebAPI- 脚本插件源码存
SysPlugin.CsharpCode,重启靠LoadAllAssembly()恢复,重名报D1900 - 插件 DLL 里的原生依赖、配置文件、权限登记三样东西最容易漏