2017 年 9 月
第 32 卷,第 9 期
ASP.NET Core - Razor 页面简化了 ASP.NET MVC 应用程序
作者 Steve Smith
Razor 页面是 ASP.NET Core 2.0 中新增的功能。借助此功能,可以在 ASP.NET Core 应用程序内更轻松地整理代码,同时拉近实现逻辑和视图模型与视图实现代码的距离。此外,还可以更轻松地开始开发 ASP.NET Core 应用程序,但这并不意味着经验丰富的 .NET 开发者就应该忽略 Razor 页面。使用 Razor 页面,还可以简化更大更复杂的 ASP.NET Core 应用程序。
模型-视图-控制器 (MVC) 模式是 Microsoft 自 2009 年以来一直支持的成熟 UI 模式,以方便开发者开发 ASP.NET 应用程序。此模式具有诸多优势,可以帮助应用程序开发者实现关注点分离,从而开发更易维护的软件。很遗憾,此模式是在默认项目模板中实现,通常会生成大量文件和文件夹,这可能会增加开发阻力,尤其是在应用程序不断增长时。在 2016 年 9 月刊的文章 (msdn.com/magazine/mt763233) 中,我介绍了此问题的一种解决方法,即功能切片。Razor 页面通过另一种全新方式解决了相同问题,尤其适用于从概念上来讲以页面为依据的方案。如果只有几乎就是静态的视图或只需执行 OST-Redirect-GET 的简单窗体,这种方法就特别有用。这些方案是 Razor 页面的最有效点,可大大减少 MVC 应用程序需要遵循的约定。
Razor 页面入门
若要开始使用 Razor 页面,可以使用 ASP.NET Core 2.0 在 Visual Studio 中新建 ASP.NET Core Web 应用程序,再选择“Razor 页面”模板,如图 1 所示。
图 1:使用“Razor 页面”模板的 ASP.NET Core 2.0 Web 应用程序
可以通过 dotnet 命令行接口 (CLI) 运行下列命令,从而实现相同目的:
dotnet new razor
至少必须运行 .NET Core SDK 2.0 版;可以运行下列命令进行检查:
dotnet --version
无论采用上述哪种方法,检查生成的项目时,都会发现其中包含新文件夹“页面”,如图 2 所示。
图 2:“Razor 页面”项目模板组织结构
尤其是,此模板不包含通常与 MVC 项目相关联的两个文件夹:“控制器”和“视图”。Razor 页面使用“页面”文件夹保留应用程序的所有页面。可以在“页面”根文件夹内随意新建文件夹,以适合应用程序的任意方式整理页面。借助 Razor 页面,开发者可以使用旨在生成优质代码的 MVC 模式,同时还能将往往会一起变化的对象归入一组,从而提高工作效率。
请注意,在版本 2 中,ASP.NET Core MVC 内置有 Razor 页面这一功能。只需添加“页面”文件夹,并将 Razor 页面文件添加到此文件夹,即可让任意 ASP.NET Core MVC 应用程序开始支持 Razor 页面。
Razor 页面规定,路由请求时需要遵循特定的文件夹结构约定。典型 MVC 应用程序中的默认页面可以通过“/”、“/Home/”和“/Home/Index”找到,而使用 Razor 页面的应用程序中的默认索引页则是与“/”和“/Index”匹配。 使用子文件夹,可以非常直观地创建应用程序的不同部分,只需相应地匹配路由即可。每个文件夹都有一个 Index.cshtml 文件,可用作自己的根页面。
查看各个页面时,大家会发现新的页面指令 @page,这是 Razor 页面必须使用的。此指令必须出现在应使用 .cshtml 扩展名的页面文件的第一行。Razor 页面的外观和行为均与基于 Razor 的视图文件非常类似,非常简单的页面可以只包括 HTML:
@page
<h1>Hello World</h1>
Razor 页面出类拔萃的地方在于,可以封装和分组 UI 详细信息。Razor 页面支持内联的或独立的基于类的页面模型,可以表示页面将显示或控制的数据元素。此外,还支持处理程序,这样就无需单独使用控制器和操作方法了。这些功能大大减少了处理 Web 应用程序的给定页面需要单独使用的文件夹和文件数量。图 3 比较了基于 MVC 的典型方法与 Razor 页面方法所需的文件夹和文件。
图 3:MVC 文件夹和文件与 Razor 页面
为了在 ASP.NET Core MVC 应用程序的上下文中展示 Razor 页面,我将使用简单的示例项目。
示例项目
为了模拟有点儿复杂又有一些不同功能区域的项目,我将重新使用我在介绍功能切片的文章中使用的示例。此示例涉及查看和管理许多不同类型的实体,包括忍者与忍者刀、海盗、植物和僵尸。假设应用程序是休闲游戏伴侣,有助于管理游戏内构造。使用典型的 MVC 组织方法,最有可能会拥有许多不同的文件夹,用于保留控制器、视图、视图模型等其他所有构造。使用 Razor 页面,可以创建简单的文件夹层次结构,映射到应用程序的 URL 结构。
在此示例中,应用程序有一个简单主页和四个不同部分,每个部分都在“页面”下有自己的子文件夹。文件夹结构非常清晰,“页面”文件夹根目录下直接就是主页 (Index.cshtml) 和一些支持文件,其他各部分分别位于自己的文件夹中,如图 4 所示。
图 4:使用 Razor 页面的文件夹组织结构
简单的页面通常不需要单独使用页面模型。例如,/Ninjas/Swords/Index.cshtml 中显示的 ninja swords 列表仅使用内联变量,如图 5 所示。
图 5:使用内联变量
@page
@{
var swords = new List<string>()
{
"Katana",
"Ninjago"
};
}
<h2>Ninja Swords</h2>
<ul>
@foreach (var item in swords)
{
<li>@item</li>
}
</ul>
<a asp-page="/Ninjas/Index">Ninja List</a>
在 Razor 块中声明的变量可以在整个页面上使用;下一部分将介绍如何通过 @functions 块声明函数和类。请注意,页面底部链接中使用了新的 asp-page 标记帮助程序。这些标记帮助程序按路由引用页面,支持绝对路径和相对路径。在此示例中,“/Ninjas/Index”也可以编写成“../Index”,甚至可以直接编写成“..”,它将路由到“忍者”文件夹中的同一 Index.cshtml Razor 页面。还可以对 <form> 元素使用 asp-page 标记帮助程序,从而指定窗体目标。由于 asp-page 标记帮助程序是在功能强大的 ASP.NET Core 路由支持基础之上构建而成,因此除了简单的相对 URL 之外,它们还支持许多 URL 生成方案。
页面模型
Razor 页面可以支持强类型页面模型。可以使用 @model 指令,为 Razor 页面指定模型(正如强类型 MVC 视图一样)。可以在 Razor 页面文件内定义模型,如图 6**** 所示。
图 6:定义模型
@page
@using WithRazorPages.Core.Interfaces;
@using WithRazorPages.Core.Model;
@model IndexModel
@functions
{
public class IndexModel : PageModel
{
private readonly IRepository<Zombie> _zombieRepository;
public IndexModel(IRepository<Zombie> zombieRepository)
{
_zombieRepository = zombieRepository;
}
// additional code omitted
}
}
也可以在单独的 codebehind 文件 Pagename.cshtml.cs 中定义页面模型。在 Visual Studio 中,遵循此约定的文件与其对应的页面文件相关联,这样就可以在两者之间轻松进行导航。可以将图 6 中的 @functions 块代码置于单独的文件中。
两种用于存储页面模型的方法都各有利弊。如果将页面模型逻辑置于 Razor 页面本身内,不仅生成的文件较少,而且可以灵活运用运行时编译,这样无需完整部署应用程序,即可更新页面逻辑。缺点是,在运行时之前,可能无法发现在 Razor 页面中定义的页面模型存在的编译错误。Visual Studio 会在打开的 Razor 文件中显示错误(而不真正进行编译)。运行 dotnet build 命令既不会编译 Razor 页面,也不会在这些文件中提供潜在错误的任何相关信息。
各个页面模型类在实现关注点分离方面稍好一些,因为 Razor 页面可以完全侧重于数据显示模板,让单独的页面模型负责处理页面数据结构和相应的处理程序。各个 codebehind 页面模型也受益于编译时错误检查,比内联页面模型更易于进行单元测试。最后,可以选择在 Razor 页面中不使用模型、使用内联模型,还是使用单独的页面模型。
路由、模型绑定和处理程序
Controller 类中通常都会用到的两项关键 MVC 功能是路由和模型绑定。大多数 ASP.NET Core MVC 应用程序都使用属性来定义路由、Http 谓词和路由参数,具体语法如下所示:
[HttpGet("{id}")]
public Task<IActionResult> GetById(int id)
如前所述,Razor 页面的路由路径需要遵循约定,并与 /Pages 文件夹层次结构中的页面位置相匹配。不过,可以将路由参数添加到 @page 指令中,从而支持路由参数。Razor 页面使用遵循 OnVerb 命名约定的处理程序(其中 Verb 是 Get、Post 等 Http 谓词),而不是使用属性指定支持的 Http 谓词。Razor 页面处理程序的行为与 MVC 控制器操作非常相似,均使用模型绑定来填充所定义的任何参数。图 7 展示的示例 Razor 页面使用路由参数、依赖关系注入和处理程序来显示记录的详细信息。
图 7:Details.cshtml - 显示给定记录 ID 的详细信息
public async Task OnGetAsync()
{
Ninjas = _ninjaRepository.List()
.Select(n => new NinjaViewModel { Id = n.Id, Name = n.Name }).ToList();
}
public async Task<IActionResult> OnPostAddAsync()
{
var entity = new Ninja()
{
Name = "Random Ninja"
};
_ ninjaRepository.Add(entity);
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var entityToDelete = _ninjaRepository.GetById(id);
_ ninjaRepository.Delete(entityToDelete);
return RedirectToPage();
}
@page "{id:int}"
@using WithRazorPages.Core.Interfaces;
@using WithRazorPages.Core.Model;
@inject IRepository<Ninja> _repository
@functions {
public Ninja Ninja { get; set; }
public IActionResult OnGet(int id)
{
Ninja = _repository.GetById(id);
// A void handler (no return) is equivalent to return Page()
return Page();
}
}
<h2>Ninja: @Ninja.Name</h2>
<div>
Id: @Ninja.Id
</div>
<div>
<a asp-page="..">Ninja List</a>
</div>
页面可以支持多个处理程序,因此能够定义 OnGet、OnPost 等。Razor 页面还引入了新的模型绑定属性 [BindProperty],这对窗体特别有用。可以将此属性应用于 Razor 页面(无论是否使用显式 PageModel)上的属性,从而对向页面发出的非 GET 请求使用数据绑定。这样一来,标记帮助程序(如 asp-for 和 asp-validation-for)就能够与指定的属性配合使用,并允许处理程序与绑定属性配合使用,而无需将属性指定为方法参数。[BindProperty] 属性也适用于 Controller。
图 8**** 展示的 Razor 页面支持用户向应用程序添加新记录。
图 8:New.cshtml - 添加新植物
@page
@using WithRazorPages.Core.Interfaces;
@using WithRazorPages.Core.Model;
@inject IRepository<Plant> _repository
@functions {
[BindProperty]
public Plant Plant { get; set; }
public IActionResult OnPost()
{
if(!ModelState.IsValid) return Page();
_repository.Add(Plant);
return RedirectToPage("./Index");
}
}
<h1>New Plant</h1>
<form method="post" class="form-horizontal">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Plant.Name" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Plant.Name" class="form-control" />
<span asp-validation-for="Plant.Name" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</form>
<div>
<a asp-page="./Index">Plant List</a>
</div>
在页面中使用同一 Http 谓词支持多个操作的情况十分常见。例如,示例中的主页面支持列出实体(以默认 GET 行为的形式),并支持删除条目或添加新条目(以 POST 请求的形式)。Razor 页面通过使用已命名的处理程序支持此方案(如图 9 所示),即在谓词后和“Async”后缀(若有)前添加名称。PageModel 基类型与 Controller 基类型类似,都可以提供许多用于返回操作结果的帮助程序方法。如果执行更新(如添加新记录),经常需要在操作成功后立即重定向用户。这样,就不会发生浏览器刷新触发重复调用服务器的问题,进而也就不会导致重复记录(或更糟的情况)发生。可以使用不含参数的 RedirectToPage,重定向到当前 Razor 页面的默认 GET 处理程序。
图 9:已命名的处理程序
public async Task OnGetAsync()
{
Ninjas = _ninjaRepository.List()
.Select(n => new NinjaViewModel { Id = n.Id, Name = n.Name }).ToList();
}
public async Task<IActionResult> OnPostAddAsync()
{
var entity = new Ninja()
{
Name = "Random Ninja"
};
_ ninjaRepository.Add(entity);
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var entityToDelete = _ninjaRepository.GetById(id);
_ ninjaRepository.Delete(entityToDelete);
return RedirectToPage();
}
可以使用 asp-page-handler 标记帮助程序,指定已命名的处理程序,应用于窗体、链接或按钮:
<a asp-page-handler="Handler">Link Text</a>
<button type="submit" asp-page-handler="delete" asp-route-id="@id">Delete</button>
asp-page-handler 标记使用路由生成 URL。默认情况下,处理程序名称和任何 asp-route-parameter 属性都可以应用为查询字符串值。上一代码中的“删除”按钮生成如下 URL:
Ninjas?handler=delete&id=1
如果希望 URL 包含处理程序,可以使用 @page 指令指定此行为:
@page "{handler?}/{id?}"
指定此路由后,“删除”按钮的已生成链接为:
Ninjas/Delete/1
筛选器
筛选器是 ASP.NET Core MVC 的另一强大功能,我在 2016 年 8 月刊 (msdn.microsoft.com/mt767699) 中介绍过这一功能。若要在单独的文件中使用页面模型,可以对 Razor 页面使用基于属性的筛选器,包括在页面模型类中添加筛选器属性。此外,为应用程序配置 MVC 时,仍可以指定全局筛选器。筛选器的最常见用途之一是,在应用程序中指定授权策略。可以全局配置基于文件夹和页面的授权策略:
services.AddMvc()
.AddRazorPagesOptions(options =>
{
options.Conventions.AuthorizeFolder("/Account/Manage");
options.Conventions.AuthorizePage("/Account/Logout");
options.Conventions.AllowAnonymousToPage("/Account/Login");
});
可以对 Razor 页面使用现有所有类型的筛选器(Action 筛选器除外,仅适用于 Controller 中的操作方法)。Razor 页面还引入了新的 Page 筛选器(由 IPageFilter 或 IAsyncPageFilter表示)。使用此筛选器,可以添加在下列时间点运行的代码:在选择特定页面处理程序后,或在处理程序方法执行前后。第一种方法可用于更改处理请求的处理程序,例如:
public void OnPageHandlerSelected(PageHandlerSelectedContext context)
{
context.HandlerMethod =
context.ActionDescriptor.HandlerMethods.First(m => m.Name == "Add");
}
选择处理程序后,便会进行模型绑定。模型绑定完成后,将调用任意页面筛选器的 OnPageHandlerExecuting 方法。此方法可以访问并控制处理程序可用的任何模型绑定数据,并快捷调用处理程序。然后,在处理程序执行后,但在操作结果执行前,调用 OnPageHandlerExecuted 方法。
从概念上讲,页面筛选器与操作筛选器非常类似,都是在操作执行前后运行。
请注意,Razor 页面根本不需要筛选器 ValidateAntiforgeryToken。此筛选器用于抵御跨网站请求伪造(CSRF 或 XSRF)攻击,但 Razor 页面自动内置有这种保护。
体系结构模式
Razor 页面是 ASP.NET Core MVC 随附的功能,利用了许多内置 ASP.NET Core MVC 功能,如路由、模型绑定和筛选器。在命名方面,它们与 2010 年 Microsoft 随 Web Matrix 一起提供的 Web Pages 功能有一些相似的地方。不同之处在于,Web Pages 主要面向新手 Web 开发者(大多数经验丰富的开发者并不太感兴趣),而 Razor 页面则将强大的体系结构设计与可接近性融为一体。
从体系结构上讲,Razor 页面并不遵循模型-视图-控制器 (MVC) 模式,因为缺少控制器。相反,Razor 页面更大程度上遵循的是许多原生应用程序开发者都应熟悉的模型-视图-视图模型 (MVVM) 模式。还可以将 Razor 页面视为页面控制器模式示例。对此,Martin Fowler 的描述为“在网站上处理有关特定页面或操作的请求的对象。此[对象]可以是页面本身,也可以是对应于相应页面的独立对象。” 当然,任何使用过 ASP.NET Web 窗体的人也应该熟悉页面控制器模式,因为这也是原始 ASP.NET 页面的工作方式。
与 ASP.NET Web 窗体不同,Razor 页面是在 ASP.NET Core 基础之上构建而成,支持松散耦合、关注点分离和 SOLID 原则。Razor 页面易于进行单元测试(如果单独使用 PageModel 类的话),并为生成可维护的干净企业应用程序提供基础。不要只将编写的 Razor 页面用作专为编程爱好者提供的“辅助”功能。请慎重对待 Razor 页面,并考虑 Razor 页面(单独使用或与传统的控制器和视图页面结合使用)能否减少在开发特定功能时需要跳转的文件夹数量,从而改进 ASP.NET Core 应用程序的设计。
迁移
虽然 Razor 页面不遵循 MVC 模式,但与现有 ASP.NET Core MVC 控制器和视图高度兼容,因此相互之间的切换通常都十分容易。若要将现有的控制器/视图页面迁移为使用 Razor 页面,请按照以下步骤操作:
- 将 Razor 视图文件复制到 /Pages 文件夹中的相应位置。
- 将 @page 指令添加到视图。如果这是仅支持 GET 的视图,操作到此结束。
- 添加名为 viewname.cshtml.cs 的 PageModel 文件,并将它放入包含 Razor 页面的文件夹中。
- 如果视图有 ViewModel,请将它复制到 PageModel 文件中。
- 将与视图关联的所有操作从 Controller 复制到 PageModel 类。
- 重命名操作,以使用 Razor 页面处理程序语法(例如,“OnGet”)。
- 使用页面方法替换对视图帮助程序方法的引用。
- 将任何构造函数依赖关系注入代码从 Controller 复制到 PageModel 中。
- 将视图的代码传递模型替换为 PageModel 上的 [BindProperty] 属性。
- 同时,将接受视图模型对象的操作方法参数替换为 [BindProperty] 属性。
结构完善的 MVC 应用程序通常都会包含视图、控制器、视图模型和绑定模型的独立文件,通常每个文件都位于项目中的不同文件夹内。使用 Razor 页面,可以将这些概念整合到一个文件夹内的多个关联文件中,同时代码还能实现逻辑关注点分离。
大多数情况下,都应该可以逆序执行这些步骤,从 Razor 页面实现迁移到基于控制器/视图的方法。可以对大多数基于 MVC 的简单操作和视图执行这些步骤。更复杂的应用程序可能需要执行其他步骤和故障排除。
后续步骤
示例包括四种版本的 NinjaPiratePlantZombie 组织应用程序,支持添加和查看各种类型数据。此示例展示了如何使用传统 MVC、包含区域的 MVC、包含功能切片的 MVC 和 Razor 页面,组织具有多个不同功能区域的应用程序。请探究这些不同的方法,看看哪些方法最适合自己的 ASP.NET Core 应用程序。有关此示例的更新后源代码,请访问 bit.ly/2eJ01cS。
Steve Smith**** 是独立的培训师、导师和顾问。他曾 14 次获得 Microsoft MVP 奖,并与数个 Microsoft 产品团队密切合作。如果团队在考虑迁移到 ASP.NET Core,或希望采用更好的编码做法,请通过 ardalis.com 或 Twitter (@ardalis) 联系他。**
衷心感谢以下 Microsoft 技术专家对本文的审阅:Ryan Nowak