六、创建 MVC 应用

当今大多数现代 web 应用都是基于模型视图控制器模式,也通常称为MVC。 您应该已经注意到,在前面的章节中,我们也使用它来构建井字游戏示例应用的基础。

因此,您已经在多个地方使用了它,甚至不知道在后台发生了什么,以及为什么应用这个特定模式很重要。

ASP 的初始前版本.NET MVC 发布于 2007 年。 它是由 Scott Guthrie 构想和设计的,他也共同创建了 ASP.NET,以及领导开发团队的 Phil Haack。 第一个打包的官方版本是 ASP.NET MVC 1,发布于 2009 年。

从那时起,ASP.NET MVC 框架多年来已经证明了自己,直到有效地成为市场标准。 微软已经成功地将其发展成为一个工业化的、高效的框架,具有很高的开发人员生产力。

有许多 web 应用的例子充分利用了 MVC 所提供的多种特性。 两个很好的例子是 Stack Overflow 和 CodePlex。 它们为开发人员提供信息,并拥有非常高的用户基础,需要同时扩展到数千甚至数百万用户。

在本章中,我们将涵盖以下主题:

  • 理解模型-视图-控制器模式
  • 为多个设备创建专用的布局
  • 使用“查看页”、“分部视图”、“查看组件”和“标记帮助程序”
  • 将 web 应用划分为多个区域
  • 应用视图引擎、单元测试和集成测试等高级概念

理解模型-视图-控制器模式

MVC 模式将应用分为三个主要层——模型、视图和控制器。 此模式的好处之一是关注点分离,也称为单一责任原则(SRP),它使独立开发、调试和测试应用特性成为可能。

当使用 MVC 模式时,用户请求被路由到控制器,控制器将使用模型检索数据和执行操作。 控制器选择相应的视图以显示给用户,同时为其提供来自模型的必要数据。

如果一个层(例如,视图)发生变化,其影响较小,因为它现在与应用的其他层(例如,控制器和模型)松散耦合。

测试应用的不同层也容易得多。 最后,通过使用这个模式,你将拥有更好的可维护性和更健壮的代码:

模型

模型包含逻辑数据结构以及应用的数据,独立于它们的可视化表示。 在 ASP.NET Core 2.0,它还支持本地化和验证,正如你在前面的章节中看到的。

模型可以与视图和控制器在同一个项目中创建,也可以在一个专门的项目中创建,以便更好地组织。 脚手架使用模型来自动生成视图。 此外,模型可以用于将表单自动绑定到实体对象。

在数据持久性方面,可以使用各种数据存储目标。 在数据库的情况下,你应该使用实体框架,它将在本书的以下章节中介绍。 在使用 Web api 时,模型是序列化的。

的观点

视图为应用提供可视化表示和用户界面元素。 当使用 ASP.NET Core 2.0,视图是使用 html 和 Razor 标记编写的。 它们通常有一个.cshtml文件扩展名。

一个视图可以包含一个完整的网页,一个网页部分(称为部分视图),或者一个布局。 在 ASP.NET Core 2.0 中,一个视图可以通过它们自己的行为被划分成逻辑的子分区,这些子分区被称为视图组件。

此外,Tag Helpers 允许您将 HTML 代码集中和封装在一个标签中,并在所有应用中使用它。 ASP.NET Core 2.0 已经包含了许多现有的 Tag Helpers 来提高开发人员的工作效率。

控制器

控制器管理模型和视图之间的交互。 它为您的应用提供逻辑行为和业务逻辑。 它选择为特定的用户请求呈现哪个视图。

一般来说,由于控制器提供应用的主要入口点,这意味着它们控制应用如何响应用户请求。

单元测试

单元测试的主要目标是验证控制器中的业务逻辑。 通常,单元测试放在它们自己的外部单元测试项目中,同时有多个测试框架可用(XUnit、NUnit 或 MSTest)。

如前所述,由于在使用 MVC 模式时一切都是完全解耦的,因此您可以使用单元测试在任何点独立于应用的其他部分来测试您的控制器。

集成测试

应用功能的端到端验证是通过集成测试完成的。 它们从应用用户的角度检查一切是否正常工作。 因此,控制器和它们对应的视图一起测试。

与单元测试一样,集成测试通常放在它们自己的测试项目中,您可以使用各种测试框架(XUnit、NUnit 或 MSTest)。 然而,对于这种类型的测试,您还需要使用 web 服务器自动化工具包。

为多个设备创建专用的布局

现代 web 应用使用网页布局来提供一致和一致的风格。 最好将 HTML 和 CSS 结合使用来定义这种布局。 在 ASP.NET Core 2.0 中,常见的网页布局定义集中在一个布局页面中。 这个页面包括所有常见的用户界面元素,比如页眉、菜单、边栏和页脚。

此外,常见的 CSS 和 JavaScript 文件在布局页面中被引用,因此它们可以在整个应用中使用。 这允许您减少视图中的代码,从而帮助您应用DRY(Don't Repeat Yourself)原则。

我们从井字游戏示例应用的早期版本就开始使用布局页面。 当我们在前一章添加它时,它第一次被介绍。 我们用它来给我们的应用一个现代的外观,正如你在这里看到的:

让我们更详细地看看布局页面,了解它是什么,以及如何利用它的特性为具有不同形式因素的多种设备(pc、电话、平板电脑等)创建专用布局。

第四章ASP 的基本概念; NET Core 2.0 -第 1 部分,我们在Views\Shared文件夹中添加了一个名为_Layout.cshtml的布局页面。 当打开这个页面并分析它的内容时,你可以看到它包含了适用于你的应用中所有页面的通用元素(header, menu, footer, CSS, JavaScripts 等):

布局页面中的公共头部部分包含 CSS 链接,也包含 SEO 标签,如标题、描述和关键字。 正如你之前已经看到的,ASP.NET Core 2.0 提供了一个简洁的特性,它允许您通过环境标记(开发、登台、生产等)自动包含特定于环境的内容。

Bootstrap 已经成为渲染menunavbar组件的准标准,这也是我们在Tic-Tac-Toe应用中也使用它的原因。

最好的做法是把常见的 JavaScript 文件放在布局页面的底部; 它们也可以根据 ASP.NET Core 环境标签。

您可以使用Views\_ViewStart.cshtml文件在中心位置定义所有页面的布局页面。 或者,如果你想手动设置一个特定的布局页面,你可以把它设置在页面的顶部:

    @{ 
      Layout = "_Layout"; 
    } 

为了更好地构建布局页面,可以定义部分来组织某些页面元素(包括公共脚本部分)的位置。 一个例子是您可以在布局页面中看到的脚本部分,它是我们在Tic-Tac-Toe应用的第一个示例中添加的。 默认情况下,它已经通过添加一个专用的元标签放在页面的底部:

    RenderSection: @RenderSection("Scripts", required: false) 

您还可以在视图中定义用于添加文件或客户端脚本的部分。 我们已经在 Email Confirmation View 的上下文中做过了,在这里你添加了一个调用客户端 JavaScriptEmailConfirmation方法的部分:

    @section Scripts{ 
      <script> 
        $(document).ready(function () { 
          EmailConfirmation('@ViewBag.Email'); 
        }); 
      </script> 
    } 

理论的讨论已经够多了,让我们自己动手做点什么吧! 让我们看看如何优化Tic-Tac-Toe应用的移动设备:

  1. 我们想要改变移动设备的显示,所以打开 Visual Studio 2017,进入解决方案资源管理器,创建一个名为Filters的新文件夹,然后添加一个名为DetectMobileFilter的新文件:
      public class DetectMobileFilter : IActionFilter 
      { 
        static Regex MobileCheck = new Regex(@"android| 
          (android|bb\d+|meego).+mobile|avantgo|bada\/|
          blackberry|blazer|compal|elaine|fennec|hiptop|
          iemobile|ip(hone|od)|iris|kindle|lge|maemo|
          midp|mmp|mobile.+firefox|netfront|
          opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|
          plucker|pocket|psp|series(4|6)0|symbian|
          treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|
          xda|xiino", RegexOptions.IgnoreCase | RegexOptions.Multiline
          | RegexOptions.Compiled); 
        static Regex MobileVersionCheck = new Regex(@"1207|
          6310|6590|3gso|4thp|50[1-6]i|770s|802s|a
          wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|
          amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|
          attw|au(di|\-m|r |s)|avan|be(ck|ll|nq)|bi(lb|rd)|
          bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|
          cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|
          devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|
          er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1
          u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|
          hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |
          _|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|
          \/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|
          ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|
          kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|
          libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|
          me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |
          o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|
          n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|
          tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|
          owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|
          pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|
          qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|
          ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|
          sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|
          sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|
          sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|
          tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|
          tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|
          vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|
          61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g|nc|nw)|
          wmlb|wonu|x700|yas\-|your|zeto|zte\-",
          RegexOptions.IgnoreCase | RegexOptions.Multiline | 
          RegexOptions.Compiled); 

        public static bool IsMobileUserAgent(
         ActionExecutedContext context) 
        { 
          string userAgent = (context.HttpContext.Request.Headers
           as FrameRequestHeaders)?.HeaderUserAgent; 
          if (context.HttpContext != null && userAgent != null) 
          { 

            if (userAgent.Length < 4) 
               return false; 

           if (MobileCheck.IsMatch(userAgent) ||
             MobileVersionCheck.IsMatch(userAgent.Substring(0, 4))) 
               return true; 
          } 
          return false; 
        } 

        public void OnActionExecuted(ActionExecutedContext context) 
        { 
          var viewResult = (context.Result as ViewResult); 
          if(viewResult == null) 
                return; 
           if (IsMobileUserAgent(context)) 
          { 
            viewResult.ViewData["Layout"] = 
             "~/Views/Shared/_LayoutMobile.cshtml"; 
          } 
          else 
          { 
            viewResult.ViewData["Layout"] =
             "~/Views/Shared/_Layout.cshtml"; 
          } 
        } 

        public void OnActionExecuting(ActionExecutingContext context) 
        { 
         } 
      } 
  1. 复制现有的Views/Shared/_Layout.cshtml文件并将副本重命名_LayoutMobile.cshtml
  2. 更新首页索引视图,删除现有的布局定义,并通过添加两个名为DesktopMobile的专用部分,根据设备显示不同的标题:
        @{ 
           ViewData["Title"] = "Home Page"; 
        } 
        <div class="row"> 
          <div class="col-lg-12"> 
            @section Desktop {<h2>@Localizer["DesktopTitle"]</h2>} 
            @section Mobile {<h2>@Localizer["MobileTitle"]</h2>} 
            <div class="alert alert-info">
        ...

Note that you must also update all the other views of the application (GameInvitation/GameInvitationConfirmation, GameInvitation/Index, Home/Index, UserRegistration/EmailConfirmation, UserRegistration/Index) with the section tags from the preceding code for now:

@section Desktop{<h2>@Localizer["DesktopTitle"]</h2>}

@section Mobile {<h2>@Localizer["MobileTitle"]</h2>}

If you do not add them in your other views, you will get errors in the next steps. However, this is only a temporary solution; we will see later in the chapter how to address this problem more effectively by using conditional statements.

  1. 更新资源文件。 这是一个英文主页索引资源文件的例子; 你还应该加上法语翻译:

  1. 通过以下步骤替换@RenderBody()元素来修改Views/Shared/_Layout.cshtml文件; Desktopsection 应该被显示,Mobilesection 应该被忽略:
        @RenderSection("Desktop", required: false) 
        @{IgnoreSection("Mobile");} 
        @RenderBody() 
  1. 通过以下步骤替换@RenderBody()元素来修改Views/Shared/_LayoutMobile.cshtml文件; Mobilesection 应该被显示,Desktopsection 应该被忽略:
        @RenderSection("Mobile", required: false) 
        @{IgnoreSection("Desktop");} 
        @RenderBody()
  1. 转到Views/_ViewStart.cshtml文件,更改所有网页的布局分配,以便能够使用前面代码中的布局定义:
        @{Layout = Convert.ToString(ViewData["Layout"]);} 
  1. 在最后一步中,更新Startup类,并将DetectMobileFilter作为参数添加到 MVC 服务注册中:
        services.AddMvc(o =>
         o.Filters.Add(typeof(DetectMobileFilter)))... 
  1. 在 Microsoft Edge 中正常启动Tic-Tac-Toe应用:

  1. 点击F12打开开发人员工具,进入仿真选项卡,选择移动设备,然后重新加载井字策略应用; 它将显示为你已经在设备上打开它:

在本节中,您已经了解了如何为特定设备提供特定的布局。 现在,您将看到如何应用其他高级 ASP.NET Core 2.0 MVC 特性有助于提高生产力和更好的应用。

使用“查看页”、“分部视图”、“查看组件”和“标记帮助程序”

ASP.NET Core 2.0 和 Razor 与 Visual Studio 2017 结合在一起时,提供了一些创建 MVC 视图的功能。 在本节中,您将看到这些功能如何帮助您提高工作效率。

例如,你可以使用 Visual Studio 2017 集成的脚手架特性来创建视图,你已经在前面的章节中多次做过了。 它允许您自动生成以下类型的视图:

  • 视图页面
  • 局部视图

你想了解它们是什么,以及如何使用 Visual Studio 2017 有效地使用它们吗? 保持警惕,因为我们现在要详细解释一切。

使用视图页面

View Pages 用于根据操作呈现结果,并对 HTTP 请求进行响应。 在 MVC 方法中,它们定义和封装应用的可见部分—表示层。 而且,它们使用.cshtml文件扩展名,默认存储在应用的Views文件夹中。

Visual Studio 2017 的脚手架特性提供了不同的视图页面模板,正如你在这里看到的:

  • Create:生成插入数据的表单
  • Edit:生成更新数据的表单
  • Delete:生成一个显示记录的表单,并带有一个确认删除的按钮
  • Details:生成一个显示记录的表单,有两个按钮,一个用于编辑表单,一个用于删除显示的记录页面
  • List:生成一个 HTML 表来显示对象列表
  • Empty:不使用任何模型生成空白页面

如果你不能使用 Visual Studio 2017 来生成你的页面视图,你可以通过自己将它们添加到Views文件夹来手动实现它们。 在这种情况下,您必须遵守 MVC 约定。 因此,在匹配动作名称的同时,将它们添加到相应的子文件夹中,以允许 ASP.NET 来查找您手动创建的视图。

让我们为Tic-Tac-Toe游戏创建排行榜,并观察其运行情况:

  1. 打开解决方案资源管理器,转到Views文件夹并创建一个新的子文件夹Leaderboard,右键单击该文件夹并在向导中选择 Add | new Item | MVC View Page,然后单击 Add 按钮:

  1. 打开创建的文件并清除其内容,通过在页面顶部添加以下说明,将排行榜视图与用户模型关联起来:
        @model IEnumerable<TicTacToe.Models.UserModel> 
  1. 最好的做法是设置它的标题变量在 SEO 标签中显示它:
        @{ViewData["Title"] = "Index";} 
  1. 使用@section元标签添加新的两个部分DesktopMobile,最后更新的时间使用@()元标签:
        <div class="row"> 
          <div class="col-lg-12"> 
            @section Desktop {<h2>@Localizer["DesktopTitle"] (
              Last updated @(System.DateTime.Now))</h2>} 
            @section Mobile {<h2>@Localizer["MobileTitle"] (
              Last updated @(System.DateTime.Now))</h2>} 
          </div> 
        </div>
  1. 为排行榜视图添加英语和法语资源文件,并为DesktopTitleMobileTitle定义本地化。
  2. 右键单击Controllers文件夹并选择 Add | Class,将其命名为LeaderboardController.cs,然后单击 Add 按钮:

  1. 更新排行榜控制器的执行:
        public class LeaderboardController : Controller 
        { 
          public IActionResult Index() 
          { 
            return View(); 
          } 
        } 

Note that Razor matches views with actions as follows: <actionname>.cshtml or <actionname>.<culture>.cshtml in the Views/<controllername> folder

  1. 更新Views/Shared文件夹中的_Layout.cshtml_LayoutMobile.cshtml文件,并添加一个 ASP.NET 标签帮助器,用于在navbar菜单中调用Home元素之后的新排行榜视图:
 <li><a asp-area="" asp-controller="Leaderboard"
            asp-action="Index">Leaderboard</a></li>
  1. 启动应用并显示新的排行榜视图:

现在您已经了解了基本知识,让我们来看看使用 Razor 时的一些更高级的技术,比如代码块、控制结构和条件语句。

代码块@{}用于设置或计算变量以及格式化数据。 你已经在前面的一个例子中的_ViewStart.cshtml文件中使用了它们来定义应该使用哪个特定的布局页面:

    @{ 
      Layout = Convert.ToString(ViewData["Layout"]); 
    } 

控制结构提供了使用循环所必需的一切。 例如,您可以对重复元素使用@for@foreach@while@do。 它们的作用与 c#中的同类完全相同。

我们现在将使用它们来实现排行榜视图:

  1. 在排行榜视图中添加一个新的 HTML 表,同时使用前面提到的控制结构:
        @model IEnumerable<TicTacToe.Models.UserModel> 
        @{ViewData["Title"] = "Index";} 
        <div class="row"> 
            <div class="col-lg-12"> 
                @section Desktop {<h2>@Localizer["DesktopTitle"] (
                 Last updated @(System.DateTime.Now))</h2>} 
                @section Mobile {<h2>@Localizer["MobileTitle"] (
                 Last updated @(System.DateTime.Now))</h2>} 
                <table class="table table-striped"> 
                    <thead> 
                        <tr> 
                            <th>Name</th> 
                            <th>Email</th> 
                            <th>Score</th> 
                        </tr> 
                    </thead> 
                    <tbody> 
                        @foreach (var user in Model) 
                    { 
                        <tr> 
                            <td>@user.FirstName  @user.LastName</td> 
                            <td>@user.Email</td> 
                            <td>@user.Score.ToString()</td> 
                        </tr> 
                    } 
                    </tbody> 
                </table> 
            </div> 
        </div> 
  1. IUserService界面中添加一个新的GetTopUsers方法,用于检索排行榜视图中显示的顶级用户:
        Task<IEnumerable<UserModel>> GetTopUsers(int numberOfUsers); 
  1. UserService中实现新的GetTopUsers方法:
        public Task<IEnumerable<UserModel>>
         GetTopUsers(int numberOfUsers) 
        { 
          return Task.Run(() =>
           (IEnumerable<UserModel>)_userStore.OrderBy(x => 
            x.Score).Take(numberOfUsers).ToList()); 
        } 
  1. 更新排行榜控制器以调用新方法:
        public class LeaderboardController : Controller 
        { 
          private IUserService _userService; 
          public LeaderboardController(IUserService userService) 
          { 
            _userService = userService; 
          } 

          public async Task<IActionResult> Index() 
          { 
            var users = await _userService.GetTopUsers(10); 
            return View(users); 
          } 
        }
  1. F5启动应用,注册多个用户,显示排行榜:

条件语句如@if@else if@else@switch允许有条件地呈现元素。 它们的工作原理与 c#完全相同。

如前所述,您需要在所有视图中定义DesktopMobile部分:

    @section Desktop { } 
    @section Mobile { } 

例如,如果您将它们暂时从排行榜索引视图中删除,并试图在ASPNETCORE_ENVIRONMENT变量设置为Development时显示它,以便激活开发人员异常页面,您将得到以下错误消息:

这是因为我们在前面的一个步骤中更改了应用的LayoutMobile布局页面,并使用了IgnoreSection指令。 不幸的是,在使用IgnoreSection指令时必须总是声明节。

但是现在你知道条件语句的存在,你已经可以看到一个更好的解了,对吧? 是的,完全; 我们必须在两个布局页面中使用条件句if来包装IgnoreSection指令。

下面是如何使用IsSectionDefined方法更新布局页面:

    @RenderSection("Desktop", required: false) 
    @if(IsSectionDefined("Mobile")){IgnoreSection("Mobile");} 
    @RenderBody() 

以下是如何更新Mobile布局页面:

    @RenderSection("Mobile", required: false) 
    @if(IsSectionDefined("Desktop")){IgnoreSection("Desktop");} 
    @RenderBody() 

启动应用,您将看到一切都按预期工作,但这次是一个更干净、更优雅、更容易理解的解决方案; 也就是说,使用 ASP 的内置功能.NET Core 2.0 和 Razor。

For additional information on Razor please visit: https://docs.microsoft.com/en-us/aspnet/core/mvc/views/razor

使用局部视图

您已经看到了如何使用 Razor 创建视图页面,但有时您必须在所有或部分视图页面中重复元素。 在这种情况下,如果您可以在视图中创建可重用组件,这不是很有帮助吗? 不出所料,ASP.NET Core 2.0 通过提供所谓的分部视图在默认情况下实现了这个特性。

分部视图在调用视图页面中呈现。 与标准 View Pages 一样,它们也有.cshtml文件扩展名。 您可以定义它们一次,然后在所有视图页面中使用它们。 这是一种通过减少代码重复来优化代码的好方法,这样可以提高质量,减少维护!

你将看到如何从现在的优化布局和移动布局页面使用单一菜单受益:

  1. 转到Views/Shared文件夹,添加一个新的 MVC 视图页面_Menu.cshtml,它将被用作菜单部分视图:

  1. 从一个布局页面复制nav栏,并粘贴到菜单部分视图:
        <nav class="navbar navbar-inverse navbar-fixed-top"> 
        ... 
        </nav>
  1. 在两个布局页面中用@Html.Partial("_Menu")替换nav栏。
  2. 启动应用并验证一切是否仍像以前一样工作。 你不应该看到任何区别,但这是一件好事; 你已经将菜单封装并集中在分部视图中了。

使用视图组件

您已经了解了如何通过使用分部视图(可以从应用中的任何视图页面调用)来创建可重用组件,并将此概念应用于Tic-Tac-Toe应用的顶部菜单。 但有时,即使是这个功能也不够。

有时你需要一些更强大,更灵活的东西,你可以在整个 web 应用中使用,甚至可以在多个 web 应用中使用。 这就是视图组件发挥作用的地方。

视图组件用于复杂的用例,这些用例需要一些代码在服务器上运行(例如,登录面板、标签云和购物车),在这些用例中,分部视图的使用受到了很大的限制,并且需要能够广泛地测试功能。

在下面的例子中,你将添加一个视图组件来管理游戏会话; 你会发现它非常类似于标准的控制器实现:

  1. 添加一个名为TurnModel的新模型到Models文件夹:
        public class TurnModel 
        { 
          public Guid Id { get; set; } 
          public Guid UserId { get; set; } 
          public UserModel User { get; set; } 
          public int X { get; set; } 
          public int Y { get; set; } 
        } 
  1. 添加一个名为GameSessionModel的新模型到Models文件夹:
        public class GameSessionModel 
        { 
          public Guid Id { get; set; } 
          public Guid UserId1 { get; set; } 
          public Guid UserId2 { get; set; } 
          public UserModel User1 { get; set; } 
          public UserModel User2 { get; set; } 
          public IEnumerable<TurnModel> Turns { get; set; } 
          public UserModel Winner { get; set; } 
          public UserModel ActiveUser { get; set; } 
          public Guid WinnerId { get; set; } 
          public Guid ActiveUserId { get; set; } 
          public bool TurnFinished { get; set; } 
        } 
  1. Services文件夹中添加一个名为GameSessionService的新服务,实现它,并提取IGameSessionService接口:
        public class GameSessionService 
        { 
          private static ConcurrentBag<GameSessionModel> _sessions; 
          static GameSessionService() 
          { 
            _sessions = new ConcurrentBag<GameSessionModel>(); 
          } 

          public Task<GameSessionModel> GetGameSession(Guid gameSessionId) 
          { 
            return Task.Run(() => _sessions.FirstOrDefault(
             x => x.Id == gameSessionId)); 
          } 
        } 
  1. Startup类中注册GameSessionService,就像您已经对所有其他服务所做的那样:
        services.AddSingleton<IGameSessionService, GameSessionService>(); 
  1. 转到解决方案资源管理器,创建一个名为Components的新文件夹,然后添加一个名为GameSessionViewComponent.cs的新类:
        [ViewComponent(Name = "GameSession")] 
        public class GameSessionViewComponent : ViewComponent 
        { 
          IGameSessionService _gameSessionService; 
          public GameSessionViewComponent(IGameSessionService
           gameSessionService) 
          { 
            _gameSessionService = gameSessionService; 
          } 

          public async Task<IViewComponentResult> InvokeAsync(Guid
           gameSessionId) 
          { 
            var session =
              await _gameSessionService.GetGameSession(gameSessionId); 
            return View(session); 
          } 
        } 
  1. 转到解决方案资源管理器并在Views/Shared文件夹中创建一个名为Components的新文件夹。 在此文件夹中为GameSessionViewComponent创建一个名为GameSession的新文件夹,然后添加一个名为default.cshtml的新视图:
        @using Microsoft.AspNetCore.Http 
        @model TicTacToe.Models.GameSessionModel 
        @{ 
          var email = Context.Session.GetString("email"); 
        } 
        @if (Model.ActiveUser?.Email == email) 
        { 
          <table> 
            @for (int rows = 0; rows < 3; rows++) 
            { 
              <tr style="height:150px;"> 
                @for (int columns = 0; columns < 3; columns++) 
                { 
                  <td style="width:150px; border:1px solid #808080"> 
                    @{ 
                      var position = Model.Turns?.FirstOrDefault(
                       turn => turn.X == columns && turn.Y == rows); 

                      if (position != null) 
                      { 
                        if (position.User?.Email == "Player1") 
                        { 
                          <i class="glyphicon glyphicon-unchecked"
                           style="width:100%;height:100%"></i> 
                        } 
                        else 
                        { 
                          <i class="glyphicon glyphicon-remove-circle"
                           style="width:100%;height:100%"></i> 
                        } 
                      } 
                      else 
                      { 
                        <a asp-action="SetPosition"
                         asp-controller="GameSession"
                         asp-route-id="@Model.Id"
                         asp-route-email="@email"
                         class="btn btn-default"
                         style="width:150px; min-height:150px;"> 
                         &nbsp; 
                        </a> 
                      } 
                  } 
                  </td> 
                } 
              </tr> 
            } 
          </table> 
        } 
        else 
        { 
          <div class="alert"> 
            <i class="glyphicon glyphicon-alert">Please wait until
               the other user has finished his turn.</i> 
          </div> 
        } 

We advise using the following syntax for putting all Partial Views for your View Components in their corresponding folders: Views\Shared\Components\<ViewComponentName>\<ViewName>

  1. 更新_ViewImports.cshtml文件以使用视图组件:
        @addTagHelper *, TicTacToe 
  1. Views文件夹中创建一个新文件夹GameSession,然后添加一个新视图Index:
        @model TicTacToe.Models.GameSessionModel 
        @section Desktop 
        { 
          <h1>Game Session @Model.Id</h1> 
          <h2>Started at @(DateTime.Now.ToShortTimeString())</h2> 
          <div class="alert alert-info"> 
            <table class="table"> 
              <tr> 
                <td>User 1:</td> 
                <td>@Model.User1?.Email (<i class="glyphicon
                 glyphicon-unchecked"></i>)</td> 
              </tr> 
              <tr> 
                <td>User 2:</td> 
                <td>@Model.User2?.Email (<i class="glyphicon
                 glyphicon-remove-circle"></i>)</td> 
              </tr> 
            </table> 
          </div> 
        } 
        @section Mobile{ 
          <h1>Game Session @Model.Id</h1> 
          <h2>Started at @(DateTime.Now.ToShortTimeString())</h2> 
          User 1: @Model.User1?.Email <i class="glyphicon
           glyphicon-unchecked"></i><br /> 
          User 2: @Model.User2?.Email (<i class="glyphicon
           glyphicon-remove-circle"></i>) 
        } 
        <vc:game-session game-session-id="@Model.Id"></vc:game-session> 
  1. GameSessionService中添加一个公共构造函数来获取 User Service 的实例:
        private IUserService _UserService; 
        public GameSessionService(IUserService userService) 
        { 
          _UserService = userService; 
        } 
  1. GameSessionService中添加创建游戏会话的方法,并更新游戏会话服务接口:
        public async Task<GameSessionModel> CreateGameSession(
         Guid invitationId, string invitedByEmail,
         string invitedPlayerEmail) 
        { 
          var invitedBy =
           await _UserService.GetUserByEmail(invitedByEmail); 
          var invitedPlayer =
           await _UserService.GetUserByEmail(invitedPlayerEmail); 

          GameSessionModel session = new GameSessionModel 
          { 
            User1 = invitedBy, 
            User2 = invitedPlayer, 
            Id = invitationId, 
            ActiveUser = invitedBy 
          }; 

          _sessions.Add(session); 
          return session; 
        }
  1. Controllers文件夹中添加一个名为GameSessionController的新控制器,并实现一个新的Index方法:
        public class GameSessionController : Controller 
        { 
          private IGameSessionService _gameSessionService; 
          public GameSessionController(IGameSessionService 
           gameSessionService) 
          { 
            _gameSessionService = gameSessionService; 
          } 

          public async Task<IActionResult> Index(Guid id) 
          { 
            var session = await _gameSessionService.GetGameSession(id); 
            if (session == null) 
            { 
              var gameInvitationService =
                Request.HttpContext.RequestServices
                .GetService<IGameInvitationService>(); 
              var invitation = await gameInvitationService.Get(id); 
              session =
                await _gameSessionService.CreateGameSession(
                 invitation.Id,invitation.InvitedBy,
                 invitation.EmailTo); 
            } 
            return View(session); 
          } 
        } 

Note that for calling RequestServices.GetService<T>(); you must also add using Microsoft.Extensions.DependencyInjection; as you have already done in other examples.

  1. 启动应用,注册新用户,邀请其他用户玩游戏,等待新的 game Session 页面显示:

使用标签的帮手

标签助手是 ASP 的一个新特性.NET Core 2.0,它允许在创建和呈现 HTML 元素时使用服务器端代码。 它们可以与现有的、众所周知的用于呈现 HTML 内容的 HTML 帮助程序相比较。

ASP.NET Core 2.0 已经提供了许多内置的标签帮助程序,比如可以在应用中使用的ImageTagHelperLabelTagHelper

在创建自己的 Tag Helpers 时,可以基于元素名称、属性名称或父标记来定位 HTML 元素。 然后你可以在你的视图中使用标准的 HTML 标签,而在 web 服务器上应用 c#编写的表示逻辑。

此外,您甚至可以创建自定义标记,正如您将在本节中看到的创建 Gravatar 标记。 你将在井字游戏应用中使用这个:

  1. 打开解决方案资源管理器并创建一个名为TagHelpers的新文件夹,然后添加一个名为GravatarTagHelper.cs的新类。
  2. 实现GravatarTagHelper.cs类; 它将用于连接到 Gravatar 在线服务,为用户检索账户照片:
        [HtmlTargetElement("Gravatar")] 
        public class GravatarTagHelper : TagHelper 
        { 
          private ILogger<GravatarTagHelper> _logger; 
          public GravatarTagHelper(ILogger<GravatarTagHelper> logger) 
          { 
            _logger = logger; 
          } 
          public string Email { get; set; } 
          public override void Process(TagHelperContext context,
           TagHelperOutput output) 
          { 
            byte[] photo = null; 
            if (CheckIsConnected()) 
            { 
              photo = GetPhoto(Email); 
            } 
            else 
            { 
              photo = File.ReadAllBytes(Path.Combine(
               Directory.GetCurrentDirectory(),
               "wwwroot", "images", "no-photo.jpg")); 
            } 

            string base64String = Convert.ToBase64String(photo); 
            output.TagName = "img"; 
            output.Attributes.SetAttribute("src",
             $"data:image/jpeg;base64,{base64String}"); 
          } 

          private bool CheckIsConnected() 
          { 
            try 
            { 
              using (var httpClient = new HttpClient()) 
              { 
                var gravatarResponse = httpClient.GetAsync(
                 "http://www.gravatar.com/avatar/").Result; 
                return (gravatarResponse.IsSuccessStatusCode); 
              } 
            } 
            catch (Exception ex) 
            { 
              _logger?.LogError($"Cannot check the gravatar
               service status: {ex}"); 
              return false; 
            } 
          } 

          private byte[] GetPhoto(string email) 
          { 
            var httpClient = new HttpClient(); 
            return httpClient.GetByteArrayAsync(
             new Uri($"http://www.gravatar.com/avatar/
             {HashEmailForGravatar(email)}")).Result; 
          } 

          private static string HashEmailForGravatar(string email) 
          { 
            var md5Hasher = MD5.Create(); 
            byte[] data = md5Hasher.ComputeHash(
             Encoding.ASCII.GetBytes(email.ToLower())); 

            var stringBuilder = new StringBuilder();  
            for (int i = 0; i < data.Length; i++) 
            { 
              stringBuilder.Append(data[i].ToString("x2")); 
            } 
            return stringBuilder.ToString(); 
          } 
        } 
  1. 打开Views/_ViewImports.cshtml文件,验证addTagHelper指令是否存在; 如果不是,将其添加到文件中:
        @addTagHelper *, TicTacToe 
  1. 更新GameInvitationController中的Index方法,存储用户电子邮件,并在一个会话变量中显示名称(名和姓):
        [HttpGet] 
        public async Task<IActionResult> Index(string email) 
        { 
          var gameInvitationModel = new GameInvitationModel {
           InvitedBy = email, Id = Guid.NewGuid() }; 
          Request.HttpContext.Session.SetString("email", email); 
          var user = await _userService.GetUserByEmail(email); 
          Request.HttpContext.Session.SetString("displayName",
           $"{user.FirstName} {user.LastName}"); 
          return View(gameInvitationModel); 
        }  
  1. 添加一个名为AccountModel的新模型到Models文件夹:
        public class AccountModel 
        { 
          public string Email { get; set; } 
          public string DisplayName { get; set; } 
        } 
  1. Views/Shared文件夹中添加一个新的分部视图_Account.cshtml:
        @model TicTacToe.Models.AccountModel 
        <li class="dropdown"> 
          <a href="#" class="dropdown-toggle" data-toggle="dropdown"> 
            <span class="glyphicon glyphicon-user"></span> 
            <strong>@Model.DisplayName</strong> 
            <span class="glyphicon glyphicon-chevron-down"></span> 
          </a> 
        <ul class="dropdown-menu" id="connected-dp"> 
        <li> 
         <div class="navbar-login"> 
          <div class="row"> 
           <div class="col-lg-4"> 
            <p class="text-center"> 
              <Gravatar email="@Model.Email"></Gravatar> 
            </p> 
           </div> 
           <div class="col-lg-8"> 
            <p class="text-left"><strong>@Model.DisplayName</strong></p> 
            <p class="text-left small"><a asp-action="Index"
              asp-controller="Account">@Model.Email</a></p> 
           </div> 
          </div> 
         </div> 
        </li> 
        <li class="divider"></li> 
        <li> 
          <div class="navbar-login navbar-login-session"> 
           <div class="row"> 
            <div class="col-lg-12"> 
             <p> 
              <a href="#" class="btn btn-danger btn-block">Log off</a>                             
             </p> 
            </div> 
           </div> 
          </div> 
        </li> 
        </ul> 
        </li> 
  1. wwwroot/css/site.css文件中添加一个新的 CSS 类:
        #connected-dp { 
          min-width: 350px; 
        } 

Note that you might need to empty your browser cache or force a refresh for the application to update the site.css file within your browser.

  1. 更新菜单部分视图,并检索页面顶部的用户显示名和电子邮件:
 @using Microsoft.AspNetCore.Http;
        @{
          var email = Context.Session.GetString("email");
          var displayName = Context.Session.GetString("displayName");
        } 
  1. 更新菜单分部视图,并添加新的帐户分部视图,从之前,位于菜单的设置元素后:
        <li> 
        @if (!string.IsNullOrEmpty(email)) 
        { 
          Html.RenderPartial("_Account",
           new TicTacToe.Models.AccountModel {
            Email = email, DisplayName = displayName }); 
        } 
        </li>
  1. 用你的电子邮件在 Gravatar 上创建一个帐户,上传一张照片,启动Tic-Tac-Toe应用,并在同一电子邮件注册。 你现在应该在顶部菜单中看到一个带有照片和显示名称的新下拉菜单:

Note that you have to be online for this to work. If you want to test your code offline, you should put a photo in the wwwroot\images folder called no-photo.jpg; otherwise, you will get an error since no offline photo can be found.

易于理解和使用,但什么时候使用视图组件和什么时候使用标签助手? 下面这些简单的规则可以帮助你决定什么时候使用这些概念:

  • 每当你需要视图模板、渲染一组元素并将服务器代码与之关联时,就会使用视图组件。
  • 标签帮助程序用于将行为附加到单个 HTML 元素,而不是一组元素。

将 web 应用划分为多个区域

有时,在处理较大的 web 应用时,将它们逻辑地分离为多个较小的功能单元可能会很有趣。 然后每个单元都可以拥有自己的控制器、视图和模型,这使得随着时间的推移更容易理解、管理、发展和维护它们。

ASP.NET Core 2.0 提供了一些基于文件夹结构的简单机制,用于将 web 应用划分为多个功能单元,也称为Areas

例如,将应用中的标准 Area 与更高级的管理 Area 分开。 然后,标准 Area 甚至可以对某些页面启用匿名访问,同时要求对其他页面进行身份验证和授权,而管理 Area 将始终要求对所有页面进行身份验证和授权。

以下公约和限制适用于区域:

  • Area 是文件夹Areas下的子目录
  • 一个区域至少包含两个子文件夹:ControllersViews
  • Area 可以包含特定的布局页面以及专用的_ViewImport.cshtml_ViewStart.cshtml文件
  • 您必须注册一个特定的路由,它允许在路由定义中使用 Areas,这样才能在您的应用中使用 Areas
  • 区域 url 推荐使用以下格式:http://<Host>/<AreaName>/<ControllerName>/<ActionName>
  • 标签助手asp-area可以用于将一个区域附加到一个 URL 上

让我们看看如何为帐户管理创建一个特定的管理区域:

  1. 打开解决方案资源管理器并创建一个名为Areas的新文件夹,右键单击该文件夹并选择 Add | Area,输入Account作为 Area 名称,然后单击 Add 按钮:

  1. 脚手架将为帐户区域创建一个专用的文件夹结构:

  1. Startup类的Configure方法中的UseMVC声明中添加Areas的新路由:
        app.UseMvc(routes => 
        { 
          routes.MapRoute(name: "areaRoute", 
           template: "{area:exists}/{controller=Home}/{action=Index}"); 

          routes.MapRoute(name: "default", 
           template: "{controller=Home}/{action=Index}/{id?}"); 
        }); 
  1. 右键单击帐户区域内的Controllers文件夹,添加一个名为HomeController的新控制器:
        [Area("Account")]
        public class HomeController : Controller
        {
          private IUserService _userService;
          public HomeController(IUserService userService) {
            _userService = userService;
          }
          public async Task<IActionResult> Index() {
            var email = HttpContext.Session.GetString("email");
            var user = await _userService.GetUserByEmail(email);
            return View(user);
          }
        }
  1. Account/Views文件夹中添加一个名为Home的新文件夹,并在这个新文件夹中添加一个名为Index的视图:
        @model TicTacToe.Models.UserModel 
        <h3>Account Details</h3> 
        <div class="container"> 
          <div class="row"> 
            <div class="col-xs-12 col-sm-6 col-md-6"> 
              <div class="well well-sm"> 
                <div class="row"> 
                  <div class="col-sm-6 col-md-4"> 
                    <Gravatar email="@Model.Email"></Gravatar> 
                  </div> 
                  <div class="col-sm-6 col-md-8"> 
                    <h4>@($"{Model.FirstName} {Model.LastName}")</h4> 
                    <p> 
                     <i class="glyphicon glyphicon-envelope"></i>&nbsp;
                    <a href="mailto:@Model.Email">@Model.Email</a> 
                    </p> 
                    <p> 
                     <i class="glyphicon glyphicon-calendar">
                     </i>&nbsp;@Model.EmailConfirmationDate 
                    </p> 
                  </div> 
                </div> 
              </div> 
            </div> 
          </div> 
        </div> 
  1. 更新帐户分部视图,并添加一个链接来显示前面的视图(就在现有的 Log off 链接之后):
        <a class="btn btn-default btn-block" asp-action="Index"
           asp-controller="Account">View Details</a> 
  1. 启动应用,注册一个新用户,并通过点击 Account 下拉菜单中的 Account Details 链接调用新区域:

我们将在此停止行政区域的实施,并在第 9 章中回到行政区域的实施.NET Core 2.0 Applications,在这里您将看到如何安全访问它。

应用先进的概念

现在我们已经看到了 ASP 的所有基本特性.NET Core 2.0 MVC,让我们来看看一些更高级的特性,这些特性可以在你作为开发人员的日常工作中帮助你。

您还将学习如何使用 Visual Studio 2017 来测试您的应用,从而为用户提供更好的质量。

使用视图引擎

在 ASP.NET Core 2.0 使用服务器端代码来渲染 HTML,它使用一个视图引擎。 默认情况下,当使用相关的.cshtml文件构建标准视图时,你可以使用带有 Razor 语法的 Razor 视图引擎。

按照惯例,该引擎能够处理位于Views文件夹中的视图。 由于它是内置的并且是默认引擎,它会自动绑定到 HTTP 请求管道,而无需您为它做任何工作。

如果您需要使用 Razor 来渲染位于Views文件夹之外的文件,并且这些文件不是直接来自 HTTP 请求管道(例如电子邮件模板),那么您就不能使用默认的 Razor 视图引擎。 相反,您需要定义自己的视图引擎,并让它负责在本例中生成 HTML 代码。

在下面的例子中,我们将解释如何使用 Razor 基于邮件模板渲染一封邮件,而邮件模板不是来自 HTTP 请求管道:

  1. 打开解决方案资源管理器,创建一个名为ViewEngines的新文件夹,添加一个名为EmailViewEngine.cs的新类,并提取其接口IEmailViewEngine:
        public class EmailViewEngine 
        { 
          private readonly IRazorViewEngine _viewEngine; 
          private readonly ITempDataProvider _tempDataProvider; 
          private readonly IServiceProvider _serviceProvider; 

          public EmailViewEngine( 
            IRazorViewEngine viewEngine, 
            ITempDataProvider tempDataProvider, 
            IServiceProvider serviceProvider) 
          { 
            _viewEngine = viewEngine; 
            _tempDataProvider = tempDataProvider; 
            _serviceProvider = serviceProvider; 
          } 
          private IView FindView(ActionContext actionContext,
           string viewName) 
          { 
            var getViewResult =
              _viewEngine.GetView(executingFilePath: null,
               viewPath: viewName, isMainPage: true); 
            if (getViewResult.Success) 
            { 
              return getViewResult.View; 
            } 
            var findViewResult = _viewEngine.FindView(actionContext,
              viewName, isMainPage: true); 
            if (findViewResult.Success) 
            { 
              return findViewResult.View; 
            } 
            var searchedLocations =
              getViewResult.SearchedLocations.Concat(
               findViewResult.SearchedLocations); 
            var errorMessage = string.Join( 
             Environment.NewLine, 
              new[] { $"Unable to find view '{viewName}'. The following
              locations were searched:" }.Concat(searchedLocations)); 

            throw new InvalidOperationException(errorMessage); 
          } 

          public async Task<string> RenderEmailToString<TModel>(string
           viewName, TModel model) 
          { 
            var actionContext = GetActionContext(); 
            var view = FindView(actionContext, viewName); 
            if (view == null) 
            { 
              throw new InvalidOperationException(string.Format(
               "Couldn't find view '{0}'", viewName)); 
            } 

            using (var output = new StringWriter()) 
            { 
              var viewContext = new ViewContext( 
                actionContext, 
                view, 
                new ViewDataDictionary<TModel>( 
                  metadataProvider: new EmptyModelMetadataProvider(), 
                  modelState: new ModelStateDictionary()) 
                { 
                  Model = model 
                }, 
                new TempDataDictionary( 
                  actionContext.HttpContext, 
                  _tempDataProvider), 
                  output, 
                  new HtmlHelperOptions()); 

              await view.RenderAsync(viewContext); 
              return output.ToString(); 
            } 
          } 
           private ActionContext GetActionContext() 
          { 
            var httpContext = new DefaultHttpContext 
            { 
              RequestServices = _serviceProvider 
            }; 
            return new ActionContext(httpContext, new RouteData(),
             new ActionDescriptor()); 
          } 
        } 
  1. 创建名为Helpers的新文件夹,并添加一个名为EmailViewRenderHelper.cs的新类:
        public class EmailViewRenderHelper 
        { 
          IHostingEnvironment _hostingEnvironment; 
          IConfiguration _configurationRoot; 
          IHttpContextAccessor _httpContextAccessor; 

          public async Task<string> RenderTemplate<T>(string template,
           IHostingEnvironment hostingEnvironment, IConfiguration 
           configurationRoot, IHttpContextAccessor httpContextAccessor,
           T model) where T:class 
          { 
            _hostingEnvironment = hostingEnvironment; 
            _configurationRoot = configurationRoot; 
            _httpContextAccessor = httpContextAccessor; 
            var renderer =
              httpContextAccessor.HttpContext.RequestServices
              .GetRequiredService<IEmailViewEngine>(); 
            return await renderer.RenderEmailToString<T>(template,
             model); 
          }         
        }
  1. Services文件夹中添加一个名为EmailTemplateRenderService的新服务,并提取其接口IEmailTemplateRenderService:
        public class EmailTemplateRenderService 
        { 
          private IHostingEnvironment _hostingEnvironment; 
          private IConfiguration _configuration; 
          private IHttpContextAccessor _httpContextAccessor; 

          public EmailTemplateRenderService(IHostingEnvironment
           hostingEnvironment, IConfiguration configuration,
           IHttpContextAccessor httpContextAccessor) 
          { 
            _hostingEnvironment = hostingEnvironment; 
            _configuration = configuration; 
            _httpContextAccessor = httpContextAccessor; 
          } 

          public async Task<string> RenderTemplate<T>(string
           templateName, T model, string host) where T : class 
          { 
            var html = await new EmailViewRenderHelper()
             .RenderTemplate(templateName, _hostingEnvironment,
             _configuration, _httpContextAccessor, model); 
            var targetDir =
              Path.Combine(Directory.GetCurrentDirectory(),
              "wwwroot", "Emails"); 

            if (!Directory.Exists(targetDir)) 
              Directory.CreateDirectory(targetDir); 

            string dateTime = DateTime.Now.ToString("ddMMHHyyHHmmss"); 
            var targetFileName = Path.Combine(targetDir,
             templateName.Replace("/", "_").Replace("\\", "_") + "." + 
             dateTime + ".html"); 
            html = html.Replace("{ViewOnLine}",
             $"{host.TrimEnd('/')}/Emails/{Path.GetFileName
             (targetFileName)}"); 
            html = html.Replace("{ServerUrl}", host); 
            File.WriteAllText(targetFileName, html); 
            return html; 
          } 
        }
  1. Startup类中注册EmailViewEngineEmailTemplateRenderService:
        services.AddTransient<IEmailTemplateRenderService,
         EmailTemplateRenderService>(); 
        services.AddTransient<IEmailViewEngine, EmailViewEngine>(); 

Note that it is required to register the EmailViewEngine and the EmailTemplateRenderService as transient because of the HTTP Context Accessor injection.

  1. Views/Shared文件夹中添加一个新的布局页面_LayoutEmail.cshtml:
        <!DOCTYPE html> 
        <html> 
        <head> 
          <meta charset="utf-8" /> 
          <meta name="viewport" content="width=device-width,
           initial-scale=1.0" /> 
          <title>@ViewData["Title"] - TicTacToe</title> 

          <environment include="Development"> 
            <link rel="stylesheet"
             href="~/lib/bootstrap/dist/css/bootstrap.css" /> 
            <link rel="stylesheet" href="~/css/site.css" /> 
          </environment> 
          <environment exclude="Development"> 
            <link rel="stylesheet"
             href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/
             css/bootstrap.min.css" 
             asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css" 
             asp-fallback-test-class="sr-only"
             asp-fallback-test-property="position"
             asp-fallback-test-value="absolute" /> 
            <link rel="stylesheet" href="~/css/site.min.css"
             asp-append-version="true" /> 
          </environment> 
        </head> 
        <body> 
          <div class="container body-content"> 
            @RenderBody() 
            <hr /> 
            <footer> 
              <p>&copy; 2017 - TicTacToe</p> 
            </footer> 
          </div> 

          <environment include="Development"> 
            <script src="~/lib/jquery/dist/jquery.js"></script> 
            <script src="~/lib/bootstrap/dist/js/bootstrap.js"></script> 
            <script src="~/js/site.js" asp-append-version="true"></script> 
          </environment> 
          <environment exclude="Development"> 
            <script src="https://ajax.aspnetcdn.com/
             ajax/jquery/jquery-2.2.0.min.js" 
             asp-fallback-src="~/lib/jquery/dist/jquery.min.js" 
             asp-fallback-test="window.jQuery" 
             crossorigin="anonymous" 
             integrity="sha384-K+ctZQ+LL8q6tP7I94W+qzQsfRV2a+
              AfHIi9k8z8l9ggpc8X+Ytst4yBo/hH+8Fk"> 
            </script> 
            <script src="https://ajax.aspnetcdn.com/ajax/bootstrap/
             3.3.7/bootstrap.min.js" 
             asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js" 
             asp-fallback-test="window.jQuery && window.jQuery.fn
              && window.jQuery.fn.modal" 
             crossorigin="anonymous" 
             integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7
              l2mCWNIpG9mGCD8wGNIcPD7Txa"> 
            </script> 
            <script src="~/js/site.min.js"
             asp-append-version="true"></script> 
          </environment> 

          @RenderSection("Scripts", required: false) 
        </body> 
        </html> 
  1. 添加一个名为UserRegistrationEmailModel的新模型到Models文件夹:
        public class UserRegistrationEmailModel 
        { 
          public string Email { get; set; } 
          public string DisplayName { get; set; } 
          public string ActionUrl { get; set; } 
        }
  1. Views文件夹中新建一个子文件夹EmailTemplates,并添加一个新视图UserRegistrationEmail:
        @model TicTacToe.Models.UserRegistrationEmailModel 
        @{ 
          ViewData["Title"] = "View"; 
          Layout = "_LayoutEmail"; 
        } 
        <h1>Welcome @Model.DisplayName</h1> 
          Thank you for registering on our website. Please click <a 
           href="@Model.ActionUrl">here</a> to confirm your email. 
  1. 更新UserRegistrationController中的EmailConfirmation方法,以便在发送任何邮件之前使用新的 Email 视图引擎:
        var userRegistrationEmail = new UserRegistrationEmailModel 
        { 
          DisplayName = $"{user.FirstName} {user.LastName}", 
          Email = email, 
          ActionUrl = Url.Action(urlAction) 
        }; 

        var emailRenderService =
          HttpContext.RequestServices.GetService
          <IEmailTemplateRenderService>(); 
        var message =
          await emailRenderService.RenderTemplate(
           "EmailTemplates/UserRegistrationEmail",
            userRegistrationEmail, Request.Host.ToString()); 

Note that for calling RequestServices.GetService<T>();, you must also add using Microsoft.Extensions.DependencyInjection; as you have already done in other examples.

  1. 启动应用并注册一个新用户,打开UserRegistrationEmail,分析其内容(查看wwwroot/Emails文件夹):

Note that a View Engine can be used for rendering email content, as seen in the preceding example, but it can also be used for rendering views outside of the Views folder, for rendering views from within a database, or for using the themes folder as in ASP.NET 4.

在本书的各个章节中,您已经看到了许多概念和代码示例,但是我们还没有讨论如何确保您的应用具有优秀的质量和可维护性。 下一节将介绍这个主题,因为它专门讨论应用测试。

通过创建单元测试和集成测试提供更好的质量

构建高质量的应用并使应用用户满意是一件困难的事情。 甚至,在应用的维护阶段,交付具有技术和功能缺陷的产品可能会导致巨大的问题。

最坏的情况是,由于维护对时间和资源的要求非常高,您将无法尽可能快地发展您的应用以降低您的上市时间,并且您将无法提供令人兴奋的新特性。 但请放心,你的竞争对手不会等待! 他们将超越你,你将失去市场份额和市场领导地位。

但是你怎样才能成功呢? 如何减少检测 bug 和功能问题的时间? 您必须测试您的代码和应用! 你必须尽可能多地做,越快越好。 众所周知,在开发阶段修复 bug 更便宜、更快,而在生产阶段修复 bug 则需要更多的时间和金钱。

当你想要成为你特定市场的未来市场领导者时,拥有一个较低的平均修复时间(MTTR)的漏洞会有很大的不同。

让我们继续开发Tic-Tac-Toe应用,然后看看如何更详细地仔细测试它:

  1. GameSessionService中添加一个名为AddTurn的新方法,并更新游戏会话服务接口:
        public async Task<GameSessionModel> AddTurn(Guid id,
         string email, int x, int y) { 
          var gameSession = _sessions.FirstOrDefault(session =>
           session.Id == id); 
          List<Models.TurnModel> turns; 
          if (gameSession.Turns != null && gameSession.Turns.Any())

            turns = new List<Models.TurnModel>(gameSession.Turns); 
          else 
            turns = new List<TurnModel>(); 

          turns.Add(new TurnModel 
          { 
            User = await _UserService.GetUserByEmail(email), 
            X = x, 
            Y = y 
          }); 

          if (gameSession.User1?.Email == email) 
            gameSession.ActiveUser = gameSession.User2; 
          else 
            gameSession.ActiveUser = gameSession.User1; 

          gameSession.TurnFinished = true; 
          _sessions = new ConcurrentBag<GameSessionModel>
          (_sessions.Where(u => u.Id != id)) 
          { 
            gameSession 
          }; 
          return gameSession; 
        } 
  1. GameSessionController中添加一个新的方法SetPosition:
        public async Task<IActionResult> SetPosition(Guid id,
         string email, int x, int y) 
        { 
          var gameSession =
            await _gameSessionService.GetGameSession(id); 
          await _gameSessionService.AddTurn(gameSession.Id, email,
           x, y); 
          return View("Index", gameSession); 
        } 
  1. 添加一个名为InvitationEmailModel的新模型到Models文件夹:
        public class InvitationEmailModel 
        { 
          public string DisplayName { get; set; } 
          public UserModel InvitedBy { get; set; } 
          public DateTime InvitedDate { get; set; } 
          public string ConfirmationUrl { get; set; } 
        } 
  1. 添加一个名为InvitationEmail的新视图到Views/EmailTemplates文件夹:
        @model TicTacToe.Models.InvitationEmailModel 
        @{ 
          ViewData["Title"] = "View"; 
          Layout = "_LayoutEmail"; 
        } 
        <h1>Welcome @Model.DisplayName</h1> 
        You have been invited by @($"{Model.InvitedBy.FirstName} 
        {Model.InvitedBy.LastName}") for playing the Tic-Tac-Toe game. 
        Please click <a href="@Model.ConfirmationUrl">here</a> for 
        joining the game.
  1. 更新GameInvitationController中的Index方法以使用之前提到的邀请邮件模板:
        [HttpPost] 
        public async Task<IActionResult> Index(
         GameInvitationModel gameInvitationModel,
         [FromServices]IEmailService emailService) 
        { 
          var gameInvitationService =
            Request.HttpContext.RequestServices.GetService
            <IGameInvitationService>(); 
          if (ModelState.IsValid) 
          { 
            try 
            { 
              var invitationModel = new InvitationEmailModel 
              { 
                DisplayName = $"{gameInvitationModel.EmailTo}", 
                InvitedBy =
                 await _userService.GetUserByEmail(
                  gameInvitationModel.InvitedBy), 
                  ConfirmationUrl = Url.Action("ConfirmGameInvitation",
                 "GameInvitation", new { id = gameInvitationModel.Id },
                  Request.Scheme, Request.Host.ToString()), 
                  InvitedDate = gameInvitationModel.ConfirmationDate 
              }; 

              var emailRenderService =
               HttpContext.RequestServices.GetService
               <IEmailTemplateRenderService>(); 
              var message =
               await emailRenderService.RenderTemplate
               <InvitationEmailModel>("EmailTemplates/InvitationEmail",
                invitationModel, Request.Host.ToString()); 
               await emailService.SendEmail(
                gameInvitationModel.EmailTo, _stringLocalizer[
                "Invitation for playing a Tic-Tac-Toe game"], message); 
            } 
            catch 
            { 

            } 

            var invitation =
             gameInvitationService.Add(gameInvitationModel).Result; 
            return RedirectToAction("GameInvitationConfirmation",
             new { id = gameInvitationModel.Id }); 
          } 
          return View(gameInvitationModel); 
        } 
  1. GameInvitationController中添加一个新的方法ConfirmGameInvitation:
        [HttpGet] 
        public IActionResult ConfirmGameInvitation (Guid id,
        [FromServices]IGameInvitationService gameInvitationService) 
        { 
          var gameInvitation = gameInvitationService.Get(id).Result; 
          gameInvitation.IsConfirmed = true; 
          gameInvitation.ConfirmationDate = DateTime.Now; 
          gameInvitationService.Update(gameInvitation); 
          return RedirectToAction("Index", "GameSession", new { id = id }); 
        }
  1. 启动应用并验证一切是否正常工作,包括启动新游戏的各种电子邮件和步骤。

现在我们已经实现了所有这些新代码,如何测试它呢? 我们如何确保它按预期工作? 您可以在调试模式下启动应用,并手动验证所有变量设置是否正确,以及应用流是否正确,但这将非常繁琐且效率不高。

你怎么能做得更好? 嗯,通过使用单元测试和集成测试,我们将在下面几节中介绍。

添加单元测试

单元测试允许您单独验证各种技术组件的行为,并确保它们按预期工作。 它们还帮助您快速识别回归并分析新开发的总体影响。

Visual Studio 2017 包含了用于单元测试的强大功能。 测试资源管理器帮助您运行单元测试以及查看和分析测试结果。 为此,您可以使用内置的 Microsoft 测试框架或其他框架,如 NUnit 或 xUnit。

此外,您可以在每次构建之后自动执行单元测试,因此如果某些内容没有按照预期工作,开发人员可以快速做出反应。

重构代码无需担心回归,因为单元测试可以确保一切仍像以前一样工作。 没有更多的借口没有最好的代码质量!

您甚至可以进一步应用测试驱动开发(TDD),即在编写实现之前编写单元测试。 此外,在这种情况下,单元测试成为某种设计文档和功能规范。

This book is about ASP.NET Core 2.0, so we will not go into too much detail about unit tests. It is, however, advised to dig deeper and familiarize yourself with all the different unit test concepts for building better applications.

现在我们将看到使用 xUnit 是多么容易,它是 ASP 首选的单元测试框架.NET Core:

  1. 添加一个新的 xUnit 测试项目(.NET Core)类型称为TicTacToe.UnitTests到 TicTacToe 解决方案:

  1. 使用 NuGet 包管理器将 xUnit 和 microt.net . test . sdk NuGet 包更新到最新版本:

  1. 添加对TicTacToeTicTacToe.Logging项目的引用:

  1. 删除自动生成的类,添加一个名为FileLoggerTests.cs的新类来测试常规类,并实现一个名为ShouldCreateALogFileAndAddEntry的新方法:
        public class FileLoggerTests 
        { 
          [Fact] 
          public void ShouldCreateALogFileAndAddEntry() 
          { 
            var fileLogger = new FileLogger(
              "Test", (category, level) => true,
               Path.Combine(Directory.GetCurrentDirectory(),
              "testlog.log")); 
            var isEnabled = fileLogger.IsEnabled(LogLevel.Information); 
            Assert.True(isEnabled); 
          } 
        } 
  1. 添加另一个名为UserServiceTests.cs的新类来测试服务,并实现一个名为ShouldAddUser的新方法:
        public class UserServiceTests 
        { 
          [Theory] 
          [InlineData("test@test.com", "test", "test", "test123!")] 
          [InlineData("test1@test.com", "test1", "test1", "test123!")] 
          [InlineData("test2@test.com", "test2", "test2", "test123!")] 
          public async Task ShouldAddUser(string email,
           string firstName, string lastName, string password) 
          { 
            var userModel = new UserModel 
            { 
              Email = email, 
              FirstName = firstName, 
              LastName = lastName, 
              Password = password 
            }; 

            var userService = new UserService(); 
            var userAdded = await userService.RegisterUser(userModel); 
            Assert.True(userAdded); 
          } 
        }
  1. 通过测试| Windows |测试资源管理器打开测试资源管理器,然后选择“全部运行”,以确保所有测试成功执行:

单元测试非常重要,但也有一定的局限性。 它们只分别测试每个技术组件,这是此类测试的主要目标。 单元测试背后的想法是快速了解所有技术组件的当前状态,一个接一个,而不减慢持续集成过程。 它们不会在真实的生产条件下测试应用,因为会模拟外部依赖关系。 相反,它们旨在快速运行,并确保每个被测试的方法不会在其他方法或类中产生意外的副作用。

如果您止步于此,您将无法在开发阶段找到最大可能的 bug。 你还需要进一步在真实环境中测试所有组件; 这就是集成测试发挥作用的地方。

添加集成测试

集成测试是对单元测试的逻辑扩展。 它们在一个能够访问外部数据源(如数据库、Web 服务和缓存)的真实环境中测试应用中多个技术组件之间的集成。

这种类型的测试的目标是确保在将各种技术组件组合在一起创建应用行为时,一切都能很好地一起工作,并提供预期的功能。

此外,集成测试应该始终具有清理步骤,以便它们可以重复运行而没有错误,并且不会在数据库或文件系统中留下任何工件。 在以下示例中,您将了解如何将集成测试应用于井字策略应用:

  1. 添加一个新的 xUnit 测试项目(。 类型为TicTacToe.IntegrationTests的 TicTacToe 解决方案,更新 NuGet 包并添加对TicTacToeTicTacToe.Logging项目的引用,如前面为单元测试项目所示。
  2. 添加Microsoft.AspNetCore.TestHostNuGet 包,可以使用 xUnit 创建全自动集成测试:

  1. 删除自动生成的类,添加名为IntegrationTests.cs的新类,并实现名为ShouldGetHomePageAsync的新方法:
        using Microsoft.Extensions.DependencyInjection; 
        using System.Reflection; 
        using System.Linq; 
        using Microsoft.CodeAnalysis; 
        ... 
        public class IntegrationTests 
        { 
          private readonly TestServer _testServer; 
          private readonly HttpClient _httpClient; 
          public IntegrationTests() 
          {             
            string applicationBasePath =
             Path.GetFullPath(Path.Combine(
              Directory.GetCurrentDirectory(),
              @"..\..\..\..\TicTacToe")); 
            Directory.SetCurrentDirectory(applicationBasePath); 
            Environment.SetEnvironmentVariable(
             "ASPNETCORE_ENVIRONMENT", "Development"); 
            var builder = new WebHostBuilder() 
             .UseKestrel() 
             .UseContentRoot(applicationBasePath) 
             .UseStartup<Startup>() 
             .ConfigureServices(services => 
            { 
              services.Configure((RazorViewEngineOptions options) => 
              { 
                var previous = options.CompilationCallback; 
                options.CompilationCallback = (context) => 
                { 
                  previous?.Invoke(context); 
                  var assembly = 
                    typeof(Startup).GetTypeInfo().Assembly; 
                  var assemblies =
                    assembly.GetReferencedAssemblies().Select(x => 
                     MetadataReference.CreateFromFile(
                      Assembly.Load(x).Location)).ToList(); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                      "mscorlib")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                      "System.Private.Corelib")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName("netstandard,
                     Version = 2.0.0.0, Culture = neutral,
                     PublicKeyToken = cc7b13ffcd2ddd51")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "System.Linq")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "System.Threading.Tasks")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "System.Runtime")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "System.Dynamic.Runtime")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "Microsoft.AspNetCore.Razor.Runtime")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "Microsoft.AspNetCore.Mvc")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "Microsoft.AspNetCore.Razor")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "Microsoft.AspNetCore.Mvc.Razor")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "Microsoft.AspNetCore.Html.Abstractions")).Location)); 

                    assemblies.Add(MetadataReference.CreateFromFile(
                     Assembly.Load(new AssemblyName(
                     "System.Text.Encodings.Web")).Location)); 
                    context.Compilation =
                     context.Compilation.AddReferences(assemblies); 
                }; 
              }); 
            }); 

            _testServer = new TestServer(builder) 
            { 
              BaseAddress = new Uri("http://localhost:5000") 

            }; 
            _httpClient = _testServer.CreateClient(); 
          } 

          [Fact] 
          public async Task ShouldGetHomePageAsync() 
          { 
            var response = await _httpClient.GetAsync("/"); 
            response.EnsureSuccessStatusCode(); 
            var responseString = await 
             response.Content.ReadAsStringAsync(); 
            Assert.Contains("Welcome to the Tic-Tac-Toe Desktop Game!",
             responseString); 
          } 
        }
  1. 在“测试资源管理器”中运行测试,并确保它们成功执行:

现在,您已经在前面的示例中了解了如何测试您的应用,您可以继续添加额外的单元和集成测试,以完全理解这些概念,并构建一个测试覆盖率,使您能够提供高质量的应用。

总结

在本章中,您了解了 MVC 模式、它的不同组件和层,以及它对于构建优秀的 ASP 是多么重要.NET Core 2.0 web 应用。

您了解了如何使用布局页面及其周围的特性来创建特定于设备的布局,从而使用户界面适应它们将要运行的设备。

此外,您已经使用 View Pages 构建了 web 应用的可见部分,即表示层。

然后我们介绍了分部视图、视图组件和标签助手,以更好地在应用的不同视图中封装和重用您的表示逻辑。

最后,我们介绍了视图引擎等高级概念,以及使用较低的MTTR来创建高质量应用的单元测试和集成测试。

在下一章中,我们将讨论 ASP.NET Core 2.0 Web API 框架以及如何构建、测试和部署 Web API 应用。