【ASP.NET Core】动态映射MVC路由

时间:2023-01-29 13:05:26

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。

【ASP.NET Core】动态映射MVC路由

 

【ASP.NET Core】动态映射MVC路由

 

内存数据库只存在内存中,所以每次运行后,要手动添加一些数据来测试。

这样,固定路由和动态路由的URL都能同时工作了。