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 所示。

使用“Razor 页面”模板的 ASP.NET Core 2.0 Web 应用程序

图 1:使用“Razor 页面”模板的 ASP.NET Core 2.0 Web 应用程序

可以通过 dotnet 命令行接口 (CLI) 运行下列命令,从而实现相同目的:

dotnet new razor

至少必须运行 .NET Core SDK 2.0 版;可以运行下列命令进行检查:

dotnet --version

无论采用上述哪种方法,检查生成的项目时,都会发现其中包含新文件夹“页面”,如图 2 所示。

“Razor 页面”项目模板组织结构

图 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 页面方法所需的文件夹和文件。

MVC 文件夹和文件与 Razor 页面

图 3:MVC 文件夹和文件与 Razor 页面

为了在 ASP.NET Core MVC 应用程序的上下文中展示 Razor 页面,我将使用简单的示例项目。

示例项目

为了模拟有点儿复杂又有一些不同功能区域的项目,我将重新使用我在介绍功能切片的文章中使用的示例。此示例涉及查看和管理许多不同类型的实体,包括忍者与忍者刀、海盗、植物和僵尸。假设应用程序是休闲游戏伴侣,有助于管理游戏内构造。使用典型的 MVC 组织方法,最有可能会拥有许多不同的文件夹,用于保留控制器、视图、视图模型等其他所有构造。使用 Razor 页面,可以创建简单的文件夹层次结构,映射到应用程序的 URL 结构。

在此示例中,应用程序有一个简单主页和四个不同部分,每个部分都在“页面”下有自己的子文件夹。文件夹结构非常清晰,“页面”文件夹根目录下直接就是主页 (Index.cshtml) 和一些支持文件,其他各部分分别位于自己的文件夹中,如图 4 所示。

使用 Razor 页面的文件夹组织结构

图 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 页面,请按照以下步骤操作:

  1. 将 Razor 视图文件复制到 /Pages 文件夹中的相应位置。
  2. @page 指令添加到视图。如果这是仅支持 GET 的视图,操作到此结束。
  3. 添加名为 viewname.cshtml.cs 的 PageModel 文件,并将它放入包含 Razor 页面的文件夹中。
  4. 如果视图有 ViewModel,请将它复制到 PageModel 文件中。
  5. 将与视图关联的所有操作从 Controller 复制到 PageModel 类。
  6. 重命名操作,以使用 Razor 页面处理程序语法(例如,“OnGet”)。
  7. 使用页面方法替换对视图帮助程序方法的引用。
  8. 将任何构造函数依赖关系注入代码从 Controller 复制到 PageModel 中。
  9. 将视图的代码传递模型替换为 PageModel 上的 [BindProperty] 属性。
  10. 同时,将接受视图模型对象的操作方法参数替换为 [BindProperty] 属性。

结构完善的 MVC 应用程序通常都会包含视图、控制器、视图模型和绑定模型的独立文件,通常每个文件都位于项目中的不同文件夹内。使用 Razor 页面,可以将这些概念整合到一个文件夹内的多个关联文件中,同时代码还能实现逻辑关注点分离。

大多数情况下,都应该可以逆序执行这些步骤,从 Razor 页面实现迁移到基于控制器/视图的方法。可以对大多数基于 MVC 的简单操作和视图执行这些步骤。更复杂的应用程序可能需要执行其他步骤和故障排除。

后续步骤

示例包括四种版本的 NinjaPiratePlant­Zombie 组织应用程序,支持添加和查看各种类型数据。此示例展示了如何使用传统 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


在 MSDN 杂志论坛讨论这篇文章