一些動態生成controller的問題
前言
最近在寫包, 一開始封裝了倉儲Repository
用於運算元據庫, 然後為了快速開發一些業務簡單的介面, 通過QueryController
, ModifyController
, CrudController
提供預設實現, 在新增介面的時候只需要新建一個 Controller, 然後繼承
public class TestController : QueryRepController<int?, TestEntity, TestEntityGet>
{
public TestController(IQueryRepository<int?, TestEntity> repository) : base(repository)
{
}
}
即可實現簡單的增刪改查功能
看到 TestController
這單薄的實現, 我突然有個想法
"既然這個controller寫得這麼簡單, 為什麼我不能嘗試靠程式碼去生成呢!?!"
雖然這個功能不一定有什麼用, 但我還是開始了踩坑
動態新建Type
經過簡單的思考, 我認為第一步應該是建立 Type
嘗試的方案一
最開始嘗試註冊一堆 typeof(QueryRepController<int?, TestEntity, TestEntityGet>)
, 然後動態建立路由
但我搞了半天也沒發現asp.net裡面有相關的功能, 也不能確定這樣生成的 Type
是正常的, 感覺這裡面能讓我栽進去的坑有很多
雖然可以自己重新實現一套路由......後面還得搞日誌, 攔截器什麼的 ?!?
我廢那勁幹嘛, 於是放棄
嘗試的方案二
之前就聽說C#有 Source Generator, 可以在編譯時直接生成程式碼
還聽說 AutoMapper
就用了這種技術(也不知道是真是假)
然後決定研究一下......
一個週末的時間讓我瞭解到, 這東西好像沒多少人用啊, 相關資料少得可憐, 網上逛了兩天, 除了說這東西很有用, 很香, 沒找著多少對我有用的資料, 也可能是我太菜了不會用
雖然最後生成了一個可以正常使用的 Controller
, 但是與我的預期有極大的差距
我期望的使用方式類似下面這種
services.AddQueryRepController<int?, TestEntity, TestEntityGet>("Test");
在使用的時候可以主動通過註冊的方式新增 Controller, 然後可以自由更改路由(比如把Test改為WTF)
搞了兩天感覺方向不對, 雖然 Source Generator 確實挺有意思的, 也有可以發揮的場景, 但至少不太符合我這時的需要
嘗試的方案三
從 Source Generator 中抽身後, 我又開始大海撈針式地尋找方案
然後在 萬能的stackoverflow 上找到了可能的方案
使用Emit擼IL
說實話在這之前我從來沒有聽說過 dotnet 中的 Emit
, 平時使用的反射也只是 GetValue
SetValue
這樣的, 這鬼東西真是讓我 大 開 眼 界
經過一番"艱苦"奮戰後, 磕磕絆絆憋出了類似下面的程式碼
public static IServiceCollection AddQueryRepController<TKey, T, GetT>(this IServiceCollection services, string route)
where T : class, IBaseEntity<TKey> where GetT : IBaseGet<T>
{
// 建一個 Assembly
AssemblyBuilder Ass = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("NewController"), AssemblyBuilderAccess.Run);
ModuleBuilder MB = Ass.DefineDynamicModule("NewController");
// 起個好聽的名字
var typeName = $"{route}Controller";
// 使用QueryRepController<TKey, T, GetT>整一個builder
var typeBuilder = MB.DefineType(typeName, TypeAttributes.Class | TypeAttributes.Public, typeof(QueryRepController<TKey, T, GetT>), null);
// 新增一個建構函式,
var ctor = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { typeof(IQueryRepository<TKey, T>) });
// 給這個建構函式編IL
var ilGenerator = ctor.GetILGenerator();
// 通過ILSpy反編譯,然後抄il
ilGenerator.Emit(OpCodes.Ldarg, 0);
ilGenerator.Emit(OpCodes.Ldarg, 1);
ilGenerator.Emit(OpCodes.Call, typeof(QueryRepController<TKey, T, GetT>).GetConstructors()[0]);
ilGenerator.Emit(OpCodes.Nop);
ilGenerator.Emit(OpCodes.Nop);
ilGenerator.Emit(OpCodes.Ret);
// 建立這個新的 type
var type = typeBuilder.CreateType();
// 根據自己的情況註冊到容器中
services.AddTransient(typeof(IQueryController<TKey, T, GetT>), type);
return services;
}
以我的水平和能力, 做到這樣已經是極限, 靠ILSpy反編譯上面的 TestController
, 抄了點程式碼(我抄我自己)
現在可以使用
services.AddQueryRepController<int?, TestEntity, TestEntityGet>("Test")
生成並註冊一個 TestController 到容器中, 也可以正常獲取例項
但是程式就是無法感知到程式碼的變化, swagger 中也看不到新加的 Controller
嘗試進行請求, 最後也以 404 Not Found
失敗告終
於是再次陷入僵局
使用ApplicationPartManager註冊controller
之前在逛園子的時候看到 Artech大佬的 文章 , 當時看的時候感覺雲裡霧裡的, 不知所云
也嘗試硬著頭皮寫, 但是沒有能夠堅持下去, 但我在完成以上步驟並且被卡住後, 再次看了大佬的文章, 豁然開朗!
為了讓這些程式整合為應用的一個有效組成部分,程式集需要封裝成ApplicationPart物件並利用ApplicationPartManager進行註冊
參考大佬的文章, 寫了如下的實現
AddControllerChangeProvider
public class AddControllerChangeProvider : IActionDescriptorChangeProvider
{
public static AddControllerChangeProvider Instance { get; } = new AddControllerChangeProvider();
public CancellationTokenSource TokenSource { get; private set; }
public bool HasChanged { get; set; }
public IChangeToken GetChangeToken()
{
TokenSource = new CancellationTokenSource();
return new CancellationChangeToken(TokenSource.Token);
}
}
又有一個 HostedService
在註冊完成後通過 ApplicationPartManager
更新註冊資訊
ChangeActionService
public class ChangeActionService : IHostedService
{
private readonly ApplicationPartManager Part;
public ChangeActionService(IServiceScopeFactory scope)
{
Part = scope.CreateScope().ServiceProvider.GetService<ApplicationPartManager>();
}
public async Task StartAsync(CancellationToken cancellationToken)
{
Part.ApplicationParts.Add(new AssemblyPart( <可以直接使用之前的AssemblyBuilder> ));
AddControllerChangeProvider.Instance.HasChanged = true;
AddControllerChangeProvider.Instance.TokenSource.Cancel();
await Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await Task.CompletedTask;
}
}
之後使用時註冊 AddControllerChangeProvider
和 ChangeActionService
services.AddSingleton<IActionDescriptorChangeProvider>(AddControllerChangeProvider.Instance);
services.AddHostedService<ChangeActionService>();
程式執行後會啟動 ChangeActionService
, 讀取我之前生成controller時使用的 AssemblyBuilder, 註冊生成的新的controller
這時就已經可以在 swagger 中看到建立的 TestController 了, 並且也能正常進行訪問
最後貼一下程式碼
之後經過一系列過度封裝, 簡單的程式碼如下(用了很多自己的封裝, 看看就好...)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMysql<TestDbContext>("localhost", 3306, "test", "root", "pwd")
// 將 TestDbContext 註冊為預設的 DbContext
.AddDefaultDbContext<TestDbContext>()
.AddControllers();
builder.Services
// 註冊一個 TestController
.AddQueryRepController<long?, TestEntity, TestEntityGet>("Test")
// 帶註釋的 Swagger
.AddSwaggerWithComments();
var app = builder.Build();
app.UseSwagger().UseSwaggerUI();
app.MapControllers();
app.Run();
public class TestDbContext : DbContext
{
public DbSet<TestEntity> Tests { get; set; }
public TestDbContext(DbContextOptions<TestDbContext> options) : base(options)
{ }
}
// 對應資料庫中的 Test 表
public class TestEntity : BaseEntity<long?>
{
public string Code { get; set; }
public int? Number { get; set; }
public bool? IsTest { get; set; }
}
// 對應 TestEntity 的 TestEntityGet, 決定介面的查詢規則
public class TestEntityGet : BaseGet<TestEntity>
{
public string? Code { get; set; }
public int? Number { get; set; }
public bool? IsTest { get; set; }
}
雖然沒啥卵用, 但是寫出這段程式碼的那一刻, 我自己是爽了, 有沒有用已經不重要的
自己寫包, 最重要的就是讓自己開心!