ASP.NET Core 中的幾大功能模塊(Razor Pages、MVC、SignalR/Blazor、Mini-API 等等)都以終結點(End Point)的方式公開。在HTTP管道上調用時,其擴展方法基本是以 Map 開頭,如 MapControllers、MapBlazorHub。
對於 MVC 應用,常用的是靜態路由匹配方式,即調用以下方法:
MapControllers
MapControllerRoute
MapDefaultControllerRoute
MapAreaControllerRoute
它們的特點是路由模板是固定的——提供 controller、action 或 area 等關鍵字段的值,如咱們嚴重熟悉的 {controller=Home}/{action=Index}/{id?}。在訪問控制器時,必須按照路由規的格式提供相應的值。比如,訪問 DouFuZha 控制器下的 Boom 操作,則需要URL:/doufuzha/boom。也就是說:控制器名稱和操作名稱都是直接指定的,沒有中途轉換。
相反地,如果 controller、action 等關鍵字段不直接提供,或者需要翻譯(轉換),這就涉及到調用 MapDynamicControllerRoute 擴展方法的事了。這個方法不要翻譯爲“動態MVC”,這樣翻譯會被誤解爲“運行時動態產生控制器”(其實真有高人這麼做,綜合運用代碼生成和動態編譯生成控制器,但實際開發中比較少用到,除非有特殊需求的龐大系統)。這裏的“動態”修飾的是路由,所以,這個擴展方法是允許開發者使用另一個路由規則來動態映射相應的控制器/操作。
這個有什麼用途呢?既然有這個功能,當然是有用的。例如
【情況一】(這是很多教程文章都用的例子)控制器名:Cats,操作:Play。
指定動態路由:{lang}/{controller}/{action}
其中,lang 表示語言,這樣就可以不同語言使用不同的 URL 了。請看:
中文:zh/貓/擼貓
英文:en/cats/play
於是,應用程序在運行階段動態轉譯,先提取 lang 字段的值,看看是什麼語言,如果是 zh ,那麼 controller=貓 要轉換爲 controller = cats;action=擼貓 要轉換爲 action=play。如果 lang 字段的值是 en,可以不轉換。
【情況二】控制器有兩個:MembersV1、MembersV2
指定動態路由:{controller}/{action}/{v}
如果 controller=members,v=1,那麼,轉換爲:controller=MembersV1,action 的值不變。
如果 controller=members,v=2,那麼,轉換爲:controller=MembersV2,action 的值不變。
如果 controller != members,不做任何轉換。
【情況三】比較奇葩,動態路由中不包含控制器名,只包含操作名稱。
{action}
控制器名稱通過 HTTP 請求的頭部來提供。
GET /cooking
accept: ...
host: ...
controller: dabaicai
於是,經過轉換,得到 controller=DaBaiCai,action=Cooking(煮大白菜)。
上面只是列舉了一些情況,其實還有很多場景是可以用到動態路由 MVC 的。
下面咱們聊聊怎麼去運用。實現 MVC 的動態路由需要知道一個核心類—— DynamicRouteValueTransformer。這是個抽象類,需要實現抽象方法 TransformAsync。該方法是異步等待的,簽名如下:
public abstract ValueTask<RouteValueDictionary> TransformAsync (HttpContext httpContext, RouteValueDictionary values);
你會發現一件有意思的事:輸入參數 values 和返回值都是路由規則的數據字典。估計你也看出這貨的思路了,是的,輸入參數的 values 動態路由模板被匹配後產生的字段集,而返回的字典是你根據實際需求轉換後的路由字段集。
如前文舉例的 {controller}/{action}/{v},如果訪問:http://abc.com/members/register/2,那麼,values 參數包含的數據爲:
controller: members action: register v: 2
members 控制器不存在的,所以,根據 v 的值進行轉換,最終返回的字典數據爲:
controller: MembersV2
action: Register
你也會發現,TransformAsync 方法還有個 HttpContext 參數。對的,這是爲了方便你分析 HTTP 請求消息用的。比如,前文的舉例中,就有個腦洞大開的,把控制器名藏在 Header 裏面。這時候就可以通過這個 HttpContext 參數訪問 Headers 集合,把 HTTP 頭的值讀出來,並作爲 controller 路由字段的值。
-----------------------------------------------------------------------------------------------------------------------
接下來,我們新手試玩。
老周這個示例是固定路由和動態路由同時使用的,這樣比較實用。好,咱先不說太多。來看看控制器的代碼。
public class HomeController : Controller { public ActionResult Main() { var context =HttpContext.RequestServices.GetRequiredService<StudentsDbContext>(); return View("MainView", context.Students.ToArray()); } public ActionResult NewStudent() => View(); public ActionResult AddNewItem(Student stu) { var dbcontext = HttpContext.RequestServices.GetRequiredService<StudentsDbContext>(); if (ModelState.IsValid) { dbcontext.Students.Add(stu); dbcontext.SaveChanges(); } return RedirectToAction("Main"); } public ActionResult DeleteItem(long sid) { var context = HttpContext.RequestServices.GetRequiredService<StudentsDbContext>(); var q = from s in context.Students where s.Id == sid select s; if(q.Count() == 1) { Student? _stu = q.FirstOrDefault(); if(_stu != null) { context.Students.Remove(_stu); context.SaveChanges(); } } return RedirectToAction("Main"); } }
這個控制器只做演示,所以比較簡單,主要是 Main 瀏覽學生信息;NewStudent 展示新增學生信息的頁面,AddNewItem 在 <form> 元素 POST 時調用,向數據庫插入一條學生信息;DeleteItem 根據學生 ID 刪除一條學生數據。
下面是 MainView 視圖,它主要瀏覽學生信息。
@model IEnumerable<Student> <div> <a asp-controller="Home" asp-action="NewStudent">新增</a> </div> <div> @if(Model.Count() == 0) { <p>什麼鬼都沒有</p> } else { <table style="width:85%;margin-top:15px;" border="1" cellpadding="2" cellspacing="0"> <thead> <tr> <th>編號</th> <th>姓名</th> <th>電郵</th> <th>年齡</th> </tr> </thead> <tbody> @foreach(var student in Model) { <tr> <td>@student.Id</td> <td>@student.Name</td> <td>@student.Email</td> <td>@student.Age</td> <td><a href="/delone/@student.Id">刪除</a></td> </tr> } </tbody> </table> } </div>
這個頁面裏已經混合了固定路由和動態路由了。
1、asp-controller、asp-action 是標記幫助器(Tag Helpers)實現的,指定要訪問的控制器和操作方法,會爲 <a> 元素自動生成鏈接,這是固定路由,生成的URL的路由模板是我們熟悉的:{controller}/{action}。
2、在每一行數據的“刪除”鏈接上,/delone/@student.Id 是動態路由,@student.Id是返回ID的值,即 /delone/1、/delone/2、/delone/6 等。這個用的是動態路由:{op}/{sid:long?},匹配後,op=delone,sid=1,sid=2……
還有,控制器代碼中,AddNewItem 和 DeleteItem 操作方法在處理完後跳轉回 Main 操作方法時,也是使用了固定路由。
return RedirectToAction("Main");
下面就是核心部分了,實現 DynamicRouteValueTransformer 抽象類。
public class CustTransform : DynamicRouteValueTransformer { public override ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpContext, RouteValueDictionary values) { // 這個動態路由要有 op 字段 if (!values.ContainsKey("op")) { return new ValueTask<RouteValueDictionary>(values); } var newValues = new RouteValueDictionary(); string? k = values["op"] as string; if (k == null || k is { Length: 0 }) { return new ValueTask<RouteValueDictionary>(values); } // 轉換路由參數 switch (k.ToLowerInvariant()) { case "addone": newValues["controller"] = "Home"; newValues["action"] = "NewStudent"; break; case "listall": newValues["controller"] = "Home"; newValues["action"] = "Main"; break; case "delone": newValues["controller"] = "Home"; newValues["action"] = "DeleteItem"; // 解析id if(values.TryGetValue("sid", out object? val) && val != null) { newValues["sid"] = val; } break; default: newValues["controller"] = "Home"; newValues["action"] = "Main"; break; } return new ValueTask<RouteValueDictionary>(newValues); } }
有許多教程的示例代碼是直接修改 values 然後將它返回,這樣會增加了代碼對 values 實例的引用,聽說這樣會導致內存泄漏。老周的代碼是 new 一個新的 RouteValueDictionary 實例,如果要要修改,就把它返回;如果不需要修改,可以把 values 直接返回。至於這樣會不會有問題,不太好說,反正目前來說能正常運行,內存佔用沒多大變化。
這裏的轉換思路是這樣的:
動態路由 {op}/{sid?},sid 可選,在刪除數據時要用。
如果 op=addone ==> controller=Home,action=NewStudent;
如果 op=listall ==> controller=Home,action=Main;
如果 op=delone,sid需要有值 ==> controller=Home,action=DeleteItem,sid=sid(這個sid從動態路由傳過來)。
如果 op=其他值,直接 controller=Home,action=Main。
現在,你明白前面視圖文件中,“刪除”鏈接的 href 爲啥是 /delone/@student.Id 了吧。
CustTransform 類要註冊到服務容器中,因爲動態路由在執行時是從服務容器獲取 DynamicRouteValueTransformer 實例的。
builder.Services.AddControllersWithViews(); builder.Services.AddSingleton<CustTransform>();
這裏我就直接註冊爲單實例模式,反正不需要反覆實例化。
在應用程序 Build 了後,需要映射終結點。
var builder = WebApplication.CreateBuilder(args); …… var app = builder.Build(); app.MapControllerRoute("app", "{controller}/{action}/{sid:long?}"); app.MapDynamicControllerRoute<CustTransform>("{op=main}/{sid:long?}"); app.Run();
以前看到網上有人問:爲什麼我用了動態路由之後,asp-controller、asp-action 等 Tag Helper 不能用了?就是因爲你只調用了 MapDynamicControllerRoute 方法,而忘了調用 MapControllerRoute 方法。
如果你同時用到了固定路由和動態路由,一定要同時調用兩個方法。放心,它們不會衝突,除非你指定的路由模板重複。如果你整個項目都用的是動態路由,那可以不調用 MapControllerRoute 方法。
這個示例老周圖省事,用的是內存數據庫(In-Memory DB)。這是 DB 上下文和模型類。
// 實體 [PrimaryKey(nameof(Id))] public class Student { public long Id { get; set; } public string? Name { get; set; } public string? Email { get; set; } public int? Age { get; set; } } // 數據庫上下文 public class StudentsDbContext : DbContext { public StudentsDbContext(DbContextOptions<StudentsDbContext> options) : base(options) { } public DbSet<Student> Students => Set<Student>(); }
Id 屬性是主鍵。
運行之後,咱們看看生成的 HTML。
內存數據庫只存在內存中,所以每次運行後,要手動添加一些數據來測試。
這樣,固定路由和動態路由的URL都能同時工作了。