【ASP.NET Core】動態對映MVC路由

2023-01-29 15:01:30

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都能同時工作了。