欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

第八章 SportsStore: 一个真实的应用程序

程序员文章站 2024-03-02 14:58:46
...

第八章 SportsStore: 一个真实的应用程序

在之前的章节中, 我构建了简单快速的MVC应用. 我描述了MVC模式, 基本C#特性, 优秀的MVC开发者使用的不同种类的工具. 现在是时候整合这些东西, 并构建一个简单并真实的电子商务应用了.
我的应用”SportsStore”将遵循随处可见的在线商城的经典做法. 我将创建一个客户可以按类别和页面浏览的在线产品目录, 一个用户可以添加删除产品的购物车, 以及一个用户可以输入发货细节的账单. 我还将创建一个管理区域, 其中包括用于管理目录的CRUD功能, 我将保护它, 只有登录的管理员才能修改.
接下来几章的目标是通过创建尽可能真实的示例, 让你了解真正的MVC开发是什么样的. 我将重点关注ASP.NET Core MVC, 所以简化了与外部系统(如数据库)的集成, 并完全忽略了一些组件, 如支付处理.
当我构建所需的基础内容时, 你可能会发现进展很慢, 但对MVC应用的初始投资会带来回报, 从而产生可维护的\可扩展的\结构良好的代码, 并对单元测试提供良好的支持.
在这个应用中我应用到的大多数MVC特性在后面的章节中都有详细描述. 我将只在这里叙述足够理解实例应用的内容, 而不会做太多重复的解释, 可以阅读后面的章节深入探究.
我将列出构建应用所需的各个步骤, 以便你可以看到MVC特性如何结合在一起. 当我创建视图时, 你应该特别注意. 如果不仔细观察, 可能会得到一些奇怪的结果.

起步

如果你打算在阅读本书的时候在自己的计算机上编写SportsStore应用程序, 那么需要安装VS, 并确保安装LocalDB选项(SQL Server).
如果你不想从头创建, 可以从github仓库下载

创建MVC项目

和之前一样创建空MVC项目SportsStore

创建文件夹结构

下一步是创建文件夹Models, Controllers, Views

配置应用程序

StartUp类负责配置应用程序. 修改如下

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace SportsStore
{
    public class Startup
    {

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
            });
        }
    }
}

ConfigureServices方法用于建立设置共享对象, 这些对象可以通过依赖注入特性(详见第十八章)在整个程序中使用. 调用的AddMvc是一个扩展方法, 用于设置MVC应用程序的共享对象
Configure方法用于设置接受和处理HTTP请求的特性. 调用的每个方法都是一个扩展方法, 设置了HTTP请求处理器.

  • UseDeveloperExceptionPage() 展示异常细节, 在开发过程中很有用, 但不应该在部署时启用, 我在第十二章中禁用了这个特性
  • UseStatusCodePages() 给HTTP请求返回一个简单的信息, 如”404 - Not Found”
  • UseStaticFiles() 启用”wwwroot”文件夹中的静态文件支持
  • UseMvc() 启用MVC框架

下一步, 我需要准备应用的Razor页面, 首先创建视图导入文件Views\_ViewImports.cshtml

@using SportsStore.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

第一句引入模型类的命名空间, 第二句启用内置的标签助手

创建单元测试项目

创建单元测试项目SportsStore.Tests, 添加MVC项目引用, 并添加Moq软件包. 或直接修改*.csproj文件, 在保存时VS将自动安装程序包

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.0</TargetFramework>

    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
    <PackageReference Include="Moq" Version="4.9.0" />
    <PackageReference Include="xunit" Version="2.3.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
    <DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SportsStore\SportsStore.csproj" />
  </ItemGroup>

</Project>

检查并运行应用

应用和测试项目都被创建并准备好开发了. 开始运行, 浏览器中将显示

Status Code: 404; Not Found

这是因为现在还没有控制器

开始领域模型

所有的项目都是从领域模型开始的, 这是MVC应用的核心. 既然是一个电子商务系统, 最明显的一个模型就是产品, 我创建了一个类Models\Product.cs

namespace SportsStore.Models {
    public class Product {
        public int ProductID { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal Price { get; set; }
        public string Category { get; set; }
    }
}

创建存储库

我需要一种从数据库获取Product对象的方式. 就像我在第三章中描述的那样, 模型应该包括在持久化数据存储库中存储和检索数据的逻辑. 现在我不用担心如何实现数据持久化, 但我需要先定义它的接口. 我添加了一个接口Models\IProductRepository.cs

using System.Linq;

namespace SportsStore.Models {

    public interface IProductRepository {
        IQueryable<Product> Products { get; }
    }
}

IQueryable<T>继承自IEnumerable<T>, 代表一个可以查询的集合, 就像管理数据库那样.
实现IProductRepository接口的类将包含很多Product对象, 不需要知道他们如何存储和传递的细节.

!IEnumerable和IQueryable的区别

IQueryable接口允许在集合中对对象进行高效查询. 本章后面的内容中, 我将会添加从数据库中检索Product对象子集的功能, 使用标准LINQ语句和IQueryable<T>接口可以从数据库中获取到我需要用到的内容, 而不需要知道存储在哪个数据库中, 以及查询的过程. 没有IQueryable<T>接口的话, 我可能就要从数据库中获取所有的Product对象, 然后舍弃一些不想要的对象, 这会显著提高程序运行成本. 这是为什么使用IQueryable<T>来连接存储库的原因. 但在使用IQueryable<T>接口的时候必须要小心, 因为每次枚举对象集合时, 再进行计算查询, 这意味着将向数据库发送一个新的查询, 会破坏IQueryable<T>的效率收益. 在这种情况下可以使用ToListToArray来转换类型
(经常用SQL语句的应该已经懵了对吧……没错.NET程序员就是习惯用LINQ和ORM框架)

创建一个伪存储库

定义接口后, 就可以实现持久性机制, 并将它连接到数据库了, 但我想应该先添加应用程序的其他部分. 为此, 我将创建IProductRepository接口的一个伪实现, 该实现将在讨论数据存储主题之前使用. 为了创建假存储库, 我添加了一个类Models\FakeProductRepository.cs

using System.Collections.Generic;
using System.Linq;

namespace SportsStore.Models
{
    public class FakeProductRepository : IProductRepository
    {

        public IQueryable<Product> Products => new List<Product> {
            new Product { Name = "Football", Price = 25 },
            new Product { Name = "Surf board", Price = 179 },
            new Product { Name = "Running shoes", Price = 95 }
        }.AsQueryable<Product>();
    }
}

FakeProductRepository实现了IProductRepository接口, 返回一个固定的Product集合. AsQueryable方法将集合转换为IQueryable<Product>.

注册存储库服务

MVC强调使用松散耦合的组件, 这意味着你可以只修改应用程序的一部分, 而不必在其他地方进行相应的更改. 这种方法将应用程序的某个部分归类为服务, 服务提供应用程序其他部分使用的特性. 提供服务的类可以被修改或替换, 而不需要在使用它的类中进行修改, 详见第十八章. 对于SportsStore应用程序, 我想创建一个存储库服务, 以允许控制器获得实现IProductRepository接口的对象, 而不用管使用的是哪个类. 这将允许我先使用之前创建的简单的FakeProductRepository来开发应用程序, 之后再用真正的存储库来替换它, 而不必对所有需要访问数据库的类进行修改. 服务在StartUp.ConfigureServices中注册

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;

namespace SportsStore
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            services.AddTransient<IProductRepository, FakeProductRepository>();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
            });
        }
    }
}

语句services.AddTransient<IProductRepository, FakeProductRepository>();告诉netCore, 当一个组件(例如控制器)需要IProductRepository接口的实现时, 应该接受FakeProductRepository类的一个实例. AddTransient方法说明每次需要接口的时候就创建一个新的对象. 别担心现在这看起来没什么意义, 你很快就会看到它如何融入应用程序的. 我会在第十八章解释细节.

展示产品列表

我花了大半章进行搭建领域模型和存储库, 你可能已经厌烦了. 所以我将切换过去, 认真使用MVC, 并在我需要的时候再回来添加模型和存储库特性.
这一节, 我将创建控制器和行为来展示存储库中的产品细节. 现在, 即使仅仅针对伪存储库的数据, 我将对他们进行排序. 我也会建立一个初始路由配置, 来将HTTP请求映射到我创建的控制器上.

!使用VS MVC脚手架

在VS中, 除了创建一个空文件外, 还可以创建脚手架. VS在新建菜单中提供专门用于创建控制器和视图的选项, 选择这些菜单项时, 系统会提示要为创建的组件选择一个场景, 例如具有读写操作的控制器, 或用于包含具有创建特定模型对象表单的视图.
我在书中没有用到的脚手架. 脚手架生成的代码和HTML非常通用, (以致于没有卵用). 在这本书中, 我的目标不仅仅是确保你知道如何建立MVC应用, 还要解释幕后的工作方式. 当创建的组件交给模版的时候, 就很难实现了.
也就是说, 这是另一种创建应用的思路, 但你的代码在风格上就会和我很不一致. 这是可行的, 但我推荐你花一些时间来理解脚手架如何工作, 这样在脚手架不能满足你的需求时, 你才能知道要做什么.

添加控制器

创建第一个控制器Controller\ProductController.cs

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;

namespace SportsStore.Controllers {
    public class ProductController : Controller {
        private IProductRepository repository;
        public ProductController(IProductRepository repo) {
            repository = repo;
        }
    }
}

当MVC需要为处理HTTP请求创建ProductController的新实例时, 它会检查构造函数(隐藏起来的), 来看它是否需要实现IProductRepository接口的对象. MVC读取StartUp中的配置, 每次都创建一个新的FakeRepository对象. MVC创建一个新的FakeRepository对象, 用它来触发ProductController的构造函数.
这就是”依赖注入”, 这种方式允许ProductController构造器通过IProductRepository接口来访问应用的存储库, 而不需要知道具体使用哪个实现类. 随着, 我将使用真正的数据库来替换伪存储库, 依赖注入意味着控制器将正常工作.

注意: 一些开发者不喜欢依赖注入, 认为这会让应用更复杂. 我不这样认为, 但如果你不熟悉依赖注入, 我推荐仔细阅读一下第十八章.

接下来, 我将添加行为List, 展示呈现整个存储库中产品列表的视图

public ViewResult List() => View(repository.Products);

添加和配置视图文件

我需要创建将内容展示给用户的视图, 但之前需要进行一些预备步骤, 来让视图更简单. 第一部是创建一个应用于所有页面的布局. 共享布局是一种让视觉风格和JS\CSS包含一致的简单方式, 我在第五章中介绍了其如何工作.
我创建了布局Views\Shared\_Layout.cshtml

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Sports Store</title>
</head>
<body>
    <div>
        @RenderBody()
    </div>
</body>
</html>

接下来我将配置应用, 来让视图默认引入该布局. 创建Views\_ViewStart.cshtml文件

{
    Layout = "_Layout";
}

现在要创建List行为对应的视图Views\Product\List.cshtml

@model IEnumerable<Product>

@foreach (var p in Model)
{
    <div>
        <h3>@p.Name</h3>
        @p.Description
        <h4>@p.Price.ToString("c")</h4>
    </div>
}

视图不知道Product对象的来源, 仅处理如何显示Product对象, 这和第三章中的”关注点分离”一致

提示: ToString("c")将数字转换为货币, 受服务器配置影响. 例如, 服务器为en-US, 则结果为”$1,002.30”, 若服务器为en-GB, 则结果为”£1,002.30”

设置默认路由

我需要告诉MVC, 应该将根URLhttp://mysite/定位到ProductController.List(). 通过编辑StartUp.cs解决.

app.UseMvc(routes => {
    routes.MapRoute(
        name: "default",
        template: "{controller=Product}/{action=List}/{id?}");
});

StartUp类的Configure方法用于设置请求管道, 该管道由许多类(又称为中间件 middleware)组成, 这些类将检查HTTP请求并生成响应. UseMvc方法设置MVC中间件, 其中一个配置选项是将URL映射到控制器和行为的方案. 我在第十五到十六章中详细描述了路由系统. 上面的更改设置了请求响应的默认行为, 除非URL不符合规定.

运行应用

基础都搭起来了. 运行即可, 结果如图. (详细运行过程不再赘述)

第八章 SportsStore: 一个真实的应用程序

已经可以展示简单的数据了, 但这些数据来自伪存储库. 在使用真正的存储库存储数据前, 先建立一个数据库, 并填充一些数据.
使用SQL Server作为数据库, 使用EF Core访问数据库, EF Core是微软.NET ORM框架. ORM框架使用C#对象来表示关系数据库中的表\列\行

注意: 你可以选择很多不同的工具和技术, 除了不同的关系数据库, 还可以使用对象数据库, 文档存储库和一些其他的数据方法. 也还有很多的.NET ORM框架, 每个框架都有不同, 可能会更适合您的项目.

我使用EF Core有几个原因: 工作起来很简单, 和LINQ集成一流(我喜欢LINQ), 并且和newCoreMVC合作很好. 早期的版本有一些失误, 但当前的版本很优雅, 而且功能丰富.
SQL server的一个不错的特性是LocalDB, 这是专门为开发人员设计的基本SQL Server特性的不需要管理的实现. 利用这个特性, 我可以在构建项目时跳过设置数据库的过程, 之后再部署到完整的SQL Server示例. 大多数MVC应用程序都部署到由专业管理员运行的托管环境中, 因此LocalDB特性意味着数据库配置可以交给DBA, 开发者可以继续编码.

安装EFCore工具包

主要的EFCore功能在创建项目时已经被VS默认添加了, 需要一个额外的包来进行数据迁移. 修改csproj, 添加Microsoft.EntityFrameworkCore.Tools.DotNet

<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />

(我在安装时提示”将新版本降级, 安装失败”)

创建数据库类

数据库上下午(database context)类是应用和EFCore之间的桥梁, 提供了用模型对象访问应用数据的途径. 我创建了类Models\ApplicationDbContext.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
namespace SportsStore.Models {
    public class ApplicationDbContext : DbContext {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options) { }
        public DbSet<Product> Products { get; set; }
    }
}

基类DbContext提供了访问EFCore的潜在功能, Products属性可以访问数据库中的Product对象. ApplicationDbContext类继承自DbContext, 添加了用于读写应用数据的属性. 现在只有一个属性, 用于访问Product对象.

创建存储库类

尽管看起来不像, 但建立数据库所需的大部分工作已经完成. 下一步是创建实现IProductRepository接口的类, 并使用EFCore获取数据. 我创建了类Models\EFProductRepository.cs

using System.Collections.Generic;
using System.Linq;

namespace SportsStore.Models {

    public class EFProductRepository : IProductRepository {

        private ApplicationDbContext context;

        public EFProductRepository(ApplicationDbContext ctx) {
            context = ctx;
        }

        public IQueryable<Product> Products => context.Products;
    }
}

我将在向应用程序添加特性时添加功能, 但目前存储库实现只是映射了一下属性. Products是一个DbSet<Product>对象, 实现了IQueryable<T>接口. 在使用EFCore时很容易实现IProductRepository接口. 这确保数据库查询将只检索需要的对象, 如本章前面所述.

定义连接字符串

连接字符串(connection string)指的是数据库的位置和名称, 并提供了应用连接数据库服务器方式的配置选项. 连接字符串被存储在appsettings.json中, 我使用模版”应用配置文件”创建, 并填写内容如下:

{
  "Data": {
    "SportStoreProducts": {
       "ConnectionString": "Server=(localdb)\\MSSQLLocalDB;Database=SportsStore;Trusted_Connection=True;MultipleActiveResultSets=true"
    }
  }
}

注意: 连接字符串必须是一个不包含换行的字符串

在配置文件的Data部分, 设置了连接字符串的名字SportsStoreProducts. Connection项目的值指定了使用数据库SportsStore.

配置应用程序

下一步是读取连接字符串, 并配置应用程序, 连接到数据库. 在StartUp中配置

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using SportsStore.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;

namespace SportsStore
{
    public class Startup
    {
        public Startup(IConfiguration configuration) => Configuration = configuration;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration["Data:SportStoreProducts:ConnectionString"]));
            services.AddTransient<IProductRepository, EFProductRepository>();
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvc(routes => {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Product}/{action=List}/{id?}");
            });
        }
    }
}

构造函数接受appsettings.json文件中的配置数据, 使用实现IConfiguration接口的对象表示. 构造函数将配置赋值给属性Configuration.
在第十四章中解释了如何读取和访问配置数据. 对SportsStore应用来讲, 我添加了一个方法调用序列来设置EFCore.

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(Configuration["Data:SportStoreProducts:ConnectionString"]));

AddDbContext扩展方法为我之前创建的数据库上下文提供了EFCore服务. 我在第十四章中提到, StartUp类中的许多方法允许服务和中间件使用选项参数配置. AddDbContext的参数是一个lambda表达式, 接受一个选项对象, 为上下文类配置数据库. 此例中, 使用UseSqlServer方法配置.
下一步是替换之前的伪实现.

services.AddTransient<IProductRepository, EFProductRepository>();

应用中, 使用IProductRepository接口实现的组件, 现在只有ProductController, 将接受一个EFProductRepository, 用于访问数据库中的数据. 我将在第十八章中介绍工作原理, 但效果是我可以将存储库无缝转移到数据库, 而不用修改ProductController类.

禁用范围验证

使用EFCore需要改变依赖注入特性的一个配置, 我将在第十八章中介绍依赖注入. Program类负责在移交控制到StartUp类之前启动和配置AspNetCore. 不作改变的话, 下一节创建数据库结构的时候会抛出异常.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace SportsStore
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseDefaultServiceProvider(options => options.ValidateScopes = false)
                .Build();
    }
}

我将在第十四章中介绍了程序配置的细节, 但这是在SportsStore中修改配置的唯一一处.

创建数据迁移

EFCore可以通过”迁移”特性, 使用模型类生成数据库结构. 在准备迁移时, EFCore将创建一个C#类, 其中包含准备数据库所需的SQL语句. 如果要修改模型类, 可以创建一个新的迁移, 其中包含反映修改所需的SQL语句. 通过这种方式, 不必担心手写和测试SQL语句, 只用关心应用中的C#模型类.

EFCore命令在命令行中执行. 打开命令行窗口, 导航到SportsStore项目文件夹, 运行一下命令

dotnet ef migrations add Initial

注意: 应该是XXX\SportsStore\SportsStore, 如果按照之前的步骤创建项目的话.

命令结束后, 会看到一个Migrations文件夹, 这里存储EFCore类. 一个文件名以<时间戳>_Initial.cs方式命名, 用于创建数据库的初始结构. 如果检查文件内容, 你会看到使用了Product模型来创建数据库结构

! add-migration 和 update-database命令

如果你有EF开发经验, 你可能用过Add-Migration命令来创建数据迁移, 用Update-Database来应用到数据库.

在netCore中, EFCore命令被集成到dotnet命令行工具, 使用Microsoft.EntityFrameworkCore.Tools.DotNet包中. 这样命令与其他dotnet命令一致, 可以自任意的shell中使用, 而上面的两个命令只能在特定的VS窗口中工作.

创建初始数据

创建了Models\SeedData.cs来提供初始数据

using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;

namespace SportsStore.Models
{
    public static class SeedData
    {
        public static void EnsurePopulated(IApplicationBuilder app)
        {
            ApplicationDbContext context = app.ApplicationServices.GetRequiredService<ApplicationDbContext>();
            context.Database.Migrate();
            if (!context.Products.Any())
            {
                context.Products.AddRange(
                    new Product { Name = "Kayak", Description = "A boat for one person", Category = "Watersports", Price = 275 },
                    new Product { Name = "Lifejacket", Description = "Protective and fashionable", Category = "Watersports", Price = 48.95m },
                    new Product { Name = "Soccer Ball", Description = "FIFA-approved size and weight", Category = "Soccer", Price = 19.50m },
                    new Product { Name = "Corner Flags", Description = "Give your playing field a professional touch", Category = "Soccer", Price = 34.95m },
                    new Product { Name = "Stadium", Description = "Flat-packed 35,000-seat stadium", Category = "Soccer", Price = 79500 },
                    new Product { Name = "Thinking Cap", Description = "Improve brain efficiency by 75%", Category = "Chess", Price = 16 },
                    new Product { Name = "Unsteady Chair", Description = "Secretly give your opponent a disadvantage", Category = "Chess", Price = 29.95m },
                    new Product { Name = "Human Chess Board", Description = "A fun game for the family", Category = "Chess", Price = 75 },
                    new Product { Name = "Bling-Bling King", Description = "Gold-plated, diamond-studded King", Category = "Chess", Price = 1200 }
                );
                context.SaveChanges();
            }
        }
    }
}

静态方法EnsurePopulated接受一个IApplicationBuilder参数, 这是StartUp.Configure()用到的接口, 用于注册HTTP中间件, 用于确认数据库中是否有内容.
EnsurePopulated方法中的ApplicationDbContext对象调用Database.Migrate用于确认应用迁移, 这意味着数据库被创建并准备好存储Product对象了. 接下来检查是否有Product对象, 如果没有, 就添加一系列初始数据, 并使用SaveChanges保存.
(这种操作在工程上确实更可靠…但个人学习web开发的话可能还是会觉得直接写sql更方便)
之后要修改StartUp.Configure来添加功能, 在末尾加入

SeedData.EnsurePopulated(app);

运行应用, 将创建数据库, 并添加初始数据(可能会消耗一段时间)
当浏览器请求默认URL时, 应用配置告诉MVC, 需要创建一个ProductController来处理请求. 创建新的ProductController意味着调用构造函数, 需要一个实现IProductRepository接口的对象. 新的配置告诉MVC应该创建一个EFProductRepository对象. EFProductRepository告诉EFCore从sQL Server加载数据, 并将其转换为产品对象. 这些都隐藏在ProductController之外, 它只接受一个实现IProductRepository接口的对象, 并使用它提供的数据. 运行结果如下.

第八章 SportsStore: 一个真实的应用程序

这种用EFCore以一系列模型对象表示SQL Server数据库的方式简单易用, 而且允许我将重点放在netCore上. 关于EFCore, 可以阅读其文档

添加分页

从上图中可以看到, List视图在一个页面上显示了数据库中的所有产品. 在本节中, 将添加分页支持, 以便视图在页面上显示较少数量的产品. 用户可以从一个页面移动到另一个页面, 以查看所有产品. 为此, 将在ProductController.List()中添加一个参数.

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using System.Linq;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public int PageSize = 4;

        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult List(int productPage = 1) =>
            View(repository.Products
                .OrderBy(p => p.ProductID)
                .Skip((productPage - 1) * PageSize)
                .Take(PageSize));
    }
}

PageSize字段指定了每页显示的数目.

!单元测试: 分页

我可以对分页功能进行单元测试, 步骤如下:

  • 创建模拟存储库
  • 注入ProductController类的构造函数
  • 调用List方法来请求特定的页面
  • 比较得到的产品对象和模拟实习中的测试数据

在测试项目中添加类ProductControllerTests.cs

using System.Collections.Generic;
using System.Linq;
using Moq;
using SportsStore.Controllers;
using SportsStore.Models;
using Xunit;

namespace SportsStore.Tests
{
    public class ProductControllerTests
    {
        [Fact]
        public void Can_Paginate()
        {
            // Arrange
            Mock<IProductRepository> mock = new Mock<IProductRepository>();
            mock.Setup(m => m.Products).Returns((new Product[] {
                new Product {ProductID = 1, Name = "P1"},
                new Product {ProductID = 2, Name = "P2"},
                new Product {ProductID = 3, Name = "P3"},
                new Product {ProductID = 4, Name = "P4"},
                new Product {ProductID = 5, Name = "P5"}
            }).AsQueryable<Product>());
            ProductController controller = new ProductController(mock.Object);
            controller.PageSize = 3;
            // Act
            IEnumerable<Product> result =
                controller.List(2).ViewData.Model as IEnumerable<Product>;
            // Assert
            Product[] prodArray = result.ToArray();
            Assert.True(prodArray.Length == 2);
            Assert.Equal("P4", prodArray[0].Name);
            Assert.Equal("P5", prodArray[1].Name);
        }
    }
}

从返回ViewResult对象的行为方法中获取数据, 需要转换ViewData.Model值到相应的属性.

显示页面链接

如果运行应用程序, 会看到页面显示四个项目, 如果想看到其他项目, 可以加深查询字符串参数, 就像

http://localhost:5000/?productPage=2

客户不能能知道是否存在这些参数, 也不打算这样进行导航, 因此我需要在页面上显示一些页面跳转按钮. 为此, 我将创建一个tagHelper, 它为我需要的链接生成HTML标记.

添加视图模型

要支持标签助手, 我将向视图传递有关可用页面数量\当前页面\存储库汇总产品总数的信息. 最简单的方法是创建一个视图模型类, 用于在控制器和视图之间传递数据. 创建Models\ViewModels\PagingInfo.cs

using System;

namespace SportsStore.Models.ViewModels {
    public class PagingInfo {

        public int TotalItems { get; set; }

        public int ItemsPerPage { get; set; }

        public int CurrentPage { get; set; }

        public int TotalPages =>
            (int)Math.Ceiling((decimal)TotalItems / ItemsPerPage);
    }
}

添加标记助手类

有了视图模型, 就可以创建一个标记助手(tagHelper)类. 创建类Infrastructure\PageLinkTagHelper.cs. 我将在第二十三到第二十五章中详细介绍标记助手

提示: Infrastructure文件夹是我放置一些类的地方, 这些类为应用程序提供管道, 但与应用程序的领域无关.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using SportsStore.Models.ViewModels;

namespace SportsStore.Infrastructure
{
    [HtmlTargetElement("div", Attributes = "page-model")]
    public class PageLinkTagHelper : TagHelper
    {
        private IUrlHelperFactory urlHelperFactory;

        public PageLinkTagHelper(IUrlHelperFactory helperFactory)
        {
            urlHelperFactory = helperFactory;
        }

        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }

        public PagingInfo PageModel { get; set; }

        public string PageAction { get; set; }

        public override void Process(TagHelperContext context,
                TagHelperOutput output)
        {
            IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
            TagBuilder result = new TagBuilder("div");
            for (int i = 1; i <= PageModel.TotalPages; i++)
            {
                TagBuilder tag = new TagBuilder("a");
                tag.Attributes["href"] = urlHelper.Action(PageAction,
                   new { productPage = i });
                tag.InnerHtml.Append(i.ToString());
                result.InnerHtml.AppendHtml(tag);
            }
            output.Content.AppendHtml(result.InnerHtml);
        }
    }
}

标记助手使用与产品页面对应的内容填充div元素. 我现在不打算详细讨论标记助手, 只要知道这是将c#逻辑引入视图的最有用方法之一就够了. 标记助手的代码可能看起来很复杂, 因为C#和HTML不容易混合. 但使用标记助手比在视图中包含C#代码块更好, 因为标记助手可以很容易地进行单元测试.
大多数MVC组件(如视图和控制器)都是自动发现的, 但是标记助手必须注册. 修改视图导入文件以注册组件

@using SportsStore.Models
@using SportsStore.Models.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper SportsStore.Infrastructure.*, SportsStore

!单元测试: 创建页面链接

要测试PageLinkTagHelper类, 调用Process方法, 提供一个TagHelperOutput对象来查看生成. 在测试项目中创建类PageLinkTagHelperTests.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Moq;
using SportsStore.Infrastructure;
using SportsStore.Models.ViewModels;
using Xunit;

namespace SportsStore.Tests
{
    public class PageLinkTagHelperTests
    {
        [Fact]
        public void Can_Generate_Page_Links()
        {
            // Arrange
            var urlHelper = new Mock<IUrlHelper>();
            urlHelper.SetupSequence(x => x.Action(It.IsAny<UrlActionContext>()))
                .Returns("Test/Page1")
                .Returns("Test/Page2")
                .Returns("Test/Page3");

            var urlHelperFactory = new Mock<IUrlHelperFactory>();
            urlHelperFactory.Setup(f =>
                    f.GetUrlHelper(It.IsAny<ActionContext>()))
                        .Returns(urlHelper.Object);

            PageLinkTagHelper helper = new PageLinkTagHelper(urlHelperFactory.Object)
            {
                PageModel = new PagingInfo
                {
                    CurrentPage = 2,
                    TotalItems = 28,
                    ItemsPerPage = 10
                },
                PageAction = "Test"
            };

            TagHelperContext ctx = new TagHelperContext(
                new TagHelperAttributeList(),
                new Dictionary<object, object>(), "");
            var content = new Mock<TagHelperContent>();
            TagHelperOutput output = new TagHelperOutput("div",
                new TagHelperAttributeList(),
                (cache, encoder) => Task.FromResult(content.Object));

            // Act
            helper.Process(ctx, output);

            // Assert
            Assert.Equal(@"<a href=""Test/Page1"">1</a>"
                + @"<a href=""Test/Page2"">2</a>"
                + @"<a href=""Test/Page3"">3</a>",
                 output.Content.GetContent());
        }
    }
}

这个测试的复杂性在于创建和使用标记助手所需对象. 使用Moq进行实现.
测试的核心部分, 通过使用包含双引号的文字字符串值来验证标记助手输. C#完全能够处理这样的字符串, 只要字符串以@开头, 并使用两组双引号""代替一组双引号. 必须记住, 不要将文字字符串分割成单独的行, 除非正在比较的字符串也同样被破坏

添加视图模型数据

我还没准备好使用标记助手, 因为我还没有向视图中提供PagingInfo实例. 可以使用ViewBag特性来实现这一点. 但我宁愿将从控制器发送到视图的所有数据封装在一个视图模型中. 为此创建了新类Models\ViewModels\ProductsListViewModel.cs

using System.Collections.Generic;
using SportsStore.Models;

namespace SportsStore.Models.ViewModels
{
    public class ProductsListViewModel
    {
        public IEnumerable<Product> Products { get; set; }
        public PagingInfo PagingInfo { get; set; }
    }
}

相应地更新ProductController, 传递ProductListViewModel对象

using Microsoft.AspNetCore.Mvc;
using SportsStore.Models;
using System.Linq;
using SportsStore.Models.ViewModels;

namespace SportsStore.Controllers
{
    public class ProductController : Controller
    {
        private IProductRepository repository;
        public int PageSize = 4;

        public ProductController(IProductRepository repo)
        {
            repository = repo;
        }

        public ViewResult List(int productPage = 1)
            => View(new ProductsListViewModel
            {
                Products = repository.Products
                    .OrderBy(p => p.ProductID)
                    .Skip((productPage - 1) * PageSize)
                    .Take(PageSize),
                PagingInfo = new PagingInfo
                {
                    CurrentPage = productPage,
                    ItemsPerPage = PageSize,
                    TotalItems = repository.Products.Count()
                }
            });
    }
}

!单元测试: 页面模型视图数据

我需要确保控制器向视图发送了正确的数据. 在ProductControllerTests中添加新的测试

[Fact]
public void Can_Send_Pagination_View_Model()
{
    // Arrange
    Mock<IProductRepository> mock = new Mock<IProductRepository>();
    mock.Setup(m => m.Products).Returns((new Product[] {
            new Product {ProductID = 1, Name = "P1"},
            new Product {ProductID = 2, Name = "P2"},
            new Product {ProductID = 3, Name = "P3"},
            new Product {ProductID = 4, Name = "P4"},
            new Product {ProductID = 5, Name = "P5"}
        }).AsQueryable<Product>());
    ProductController controller =
        new ProductController(mock.Object) { PageSize = 3 };

    // Act
    ProductsListViewModel result = controller.List(2).ViewData.Model as ProductsListViewModel;
    // Assert
    PagingInfo pageInfo = result.PagingInfo;
    Assert.Equal(2, pageInfo.CurrentPage);
    Assert.Equal(3, pageInfo.ItemsPerPage);
    Assert.Equal(5, pageInfo.TotalItems);
    Assert.Equal(2, pageInfo.TotalPages);
}

我还需要修改之前的Can_Paginate测试 (我刚注意到命名风格变了)

[Fact]
public void Can_Paginate()
{
    // Arrange
    Mock<IProductRepository> mock = new Mock<IProductRepository>();
    mock.Setup(m => m.Products).Returns((new Product[] {
        new Product {ProductID = 1, Name = "P1"},
        new Product {ProductID = 2, Name = "P2"},
        new Product {ProductID = 3, Name = "P3"},
        new Product {ProductID = 4, Name = "P4"},
        new Product {ProductID = 5, Name = "P5"}
    }).AsQueryable<Product>());
    ProductController controller = new ProductController(mock.Object);
    controller.PageSize = 3;
    // Act
    ProductsListViewModel result =
        controller.List(2).ViewData.Model as ProductsListViewModel;
    // Assert
    Product[] prodArray = result.Products.ToArray();
    Assert.True(prodArray.Length == 2);
    Assert.Equal("P4", prodArray[0].Name);
    Assert.Equal("P5", prodArray[1].Name);
}

考虑这两个测试之间的重复程度, 通常我会设置一个通用的设置方法. 但由于我在侧边栏中提到测试, 所以我将所有测试分开.

然后应该更新视图List.cshtml

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    <div>
        <h3>@p.Name</h3>
        @p.Description
        <h4>@p.Price.ToString("c")</h4>
    </div>
}

我直接修改了@model, 修改了foreach循环

展示页面链接

我已经准备好了要将页面链接添加到List视图所需的所有内容. 我创建了包含分页信息的视图模型, 更新了控制器和视图, 剩下的就是添加标记助手, 创建HTML元素. 在List.cshtml文件中

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    <div>
        <h3>@p.Name</h3>
        @p.Description
        <h4>@p.Price.ToString("c")</h4>
    </div>
}

<div page-model="@Model.PagingInfo" page-action="List"></div>

运行程序后会看到新页面. 链接将用户从一个页面带到另一个页面, 并允许浏览要销售的产品. Razor在div元素上找到page-model属性时, 请求PageLinkTagHelper类转换元素, 生成图中的一组链接

第八章 SportsStore: 一个真实的应用程序

!为什么不使用GridView?

如果你以前用过ASP.NET, 你可能会认为我为了得到一个不起眼的成果做太多工作了. 为了得到一个简单的分页产品列表, 我花了很多页. 如果使用Web Form, 也可以做同样的事情, 使用GridView或ListView, 直接连接到Products数据库.
我在这一章完成的内容看起来不多, 但与拖放控件完全不同. 首先, 我构建了一个应用程序, 它有一个具有良好的可维护的架构, 包括适当的关注点隔离. 与最简单的使用ListView控件不同, 我没有耦合UI和数据库, 直接使用控件可以快速产生结果, 但随时间推移会越来越痛苦. 其次, 我一直在创建单元测试, 这些测试允许我以一种自然的方式来验证应用程序的行为, 这对于复杂的web表单控件来说基本是不可能的. 最后, 我在本章中大量介绍了创建基础设施的过程, 我正在该基础设施上构建应用程序. 例如, 我只需要定义和实现存储库一次, 之后就能快速轻松地构建并测试新特性.
当然, 所有这些比不上web form直接就能给出结果. 但正如在第三章中解释的那样, 即时性带来的成本在大型项目中是难以忍受的.

改善URL

分页URL看起来可能很丑, 应该做一些调整, 如从

http://localhost/?productPage=2

调整为

http://localhost/Page2

StartUp.cs中添加路由映射

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseDeveloperExceptionPage();
    app.UseStatusCodePages();
    app.UseStaticFiles();
    app.UseMvc(routes => {
        routes.MapRoute(
            name: "pagination",
            template: "Products/Page{productPage}",
            defaults: new { Controller = "Product", action = "List" });

        routes.MapRoute(
            name: "default",
            template: "{controller=Product}/{action=List}/{id?}");
    });
    SeedData.EnsurePopulated(app);
}

很重要的是要在默认路由之前添加新路由. 在第十五章中可以了解到, 路由系统按照列出路由的顺序处理路由.
这是更改分页URL方案所需的唯一更改. MVC和路由功能紧密集成, 应用会自动反映URL的变化, 包括由标记助手生成的URL. 详见第十五到十六章.

运行应用将得到如下结果

第八章 SportsStore: 一个真实的应用程序

为内容改变风格

安装bootstrap包

添加bower配置

/* .bowerrc */

{
  "registry": "https://registry.bower.io",
  "directory": "wwwroot/lib"
}
/* bower.json */

{
  "name": "asp.net",
  "private": true,
  "dependencies": {
    "bootstrap": "4.1.2"
  }
}

应用bootstrap

布局文件

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet"
          asp-href-include="/lib/bootstrap/dist/**/*.min.css"
          asp-href-exclude="**/*-reboot*,**/*-grid*" />
    <title>SportsStore</title>
</head>
<body>
    <div class="navbar navbar-inverse bg-inverse" role="navigation">
        <a class="navbar-brand" href="#">SPORTS STORE</a>
    </div>
    <div class="row m-1 p-1">
        <div id="categories" class="col-3">
            Put something useful here later
        </div>
        <div class="col-9">
            @RenderBody()
        </div>
    </div>
</body>
</html>

使用内置标签asp-href-includeasp-href-exclude来引入按模式匹配的文件

List视图:

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    <div class="card card-outline-primary m-1 p-1">
        <div class="bg-faded p-1">
            <h4>
                @p.Name
                <span class="badge badge-pill badge-primary" style="float:right">
                    <small>@p.Price.ToString("c")</small>
                </span>
            </h4>
        </div>
        <div class="card-text p-1">@p.Description</div>
    </div>
}
<div page-model="@Model.PagingInfo" page-action="List" page-classes-enabled="true"
     page-class="btn" page-class-normal="btn-secondary"
     page-class-selected="btn-primary" class="btn-group pull-right m-1">
</div>

我希望改变PageLinkTagHelper生成的按钮的样式, 但不希望将类直接硬编码到c#代码中, 这样会使修改按钮的外观或者在其他地方重用标记助手变得困难. 反之, 我在div元素上定义了自定义属性, 这些属性指定了我需要的类, 对生成的元素进行样式化.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using SportsStore.Models.ViewModels;

namespace SportsStore.Infrastructure
{
    [HtmlTargetElement("div", Attributes = "page-model")]
    public class PageLinkTagHelper : TagHelper
    {
        private IUrlHelperFactory urlHelperFactory;

        public PageLinkTagHelper(IUrlHelperFactory helperFactory)
        {
            urlHelperFactory = helperFactory;
        }

        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }
        public PagingInfo PageModel { get; set; }
        public string PageAction { get; set; }
        public bool PageClassesEnabled { get; set; } = false;
        public string PageClass { get; set; }
        public string PageClassNormal { get; set; }
        public string PageClassSelected { get; set; }

        public override void Process(TagHelperContext context,
                TagHelperOutput output)
        {
            IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
            TagBuilder result = new TagBuilder("div");
            for (int i = 1; i <= PageModel.TotalPages; i++)
            {
                TagBuilder tag = new TagBuilder("a");
                tag.Attributes["href"] = urlHelper.Action(PageAction,
                   new { productPage = i });
                if (PageClassesEnabled)
                {
                    tag.AddCssClass(PageClass);
                    tag.AddCssClass(i == PageModel.CurrentPage
                        ? PageClassSelected : PageClassNormal);
                }
                tag.InnerHtml.Append(i.ToString());
                result.InnerHtml.AppendHtml(tag);
            }
            output.Content.AppendHtml(result.InnerHtml);
        }
    }
}

属性值会被标记助手自动更新. 重新运行应用, 会看到页面外观已经有所改进.

第八章 SportsStore: 一个真实的应用程序

创建部分视图

在本章的结尾, 我将重构应用程序以简化List.cshtml. 我将创建一个部分视图, 这是一个可以像模板一样嵌入另一个视图的你若片段. 我将在第二十一章详细描述部分视图. 当需要相同的内容出现在应用程序的不同位置时, 有助于减少重复. 不需要将相同的Razor标记复制粘贴到多个视图中, 只需要在局部视图中定义一次. 为了创建部分视图, 我添加了Views\Shared\ProductSummary.cshtml文件

@model Product

<div class="card card-outline-primary m-1 p-1">
    <div class="bg-faded p-1">
        <h4>
            @Model.Name
            <span class="badge badge-pill badge-primary" style="float:right">
                <small>@Model.Price.ToString("c")</small>
            </span>
        </h4>
    </div>
    <div class="card-text p-1">@Model.Description</div>
</div>

现在修改List.cshtml

@model ProductsListViewModel

@foreach (var p in Model.Products)
{
    @Html.Partial("ProductSummary", p)
}

<div page-model="@Model.PagingInfo" page-action="List" page-classes-enabled="true"
     page-class="btn" page-class-normal="btn-secondary"
     page-class-selected="btn-primary" class="btn-group pull-right m-1">
</div>

简单的说就是将之前显示Product的部分移动到ProductSummary.cshtml中.

第八章 SportsStore: 一个真实的应用程序

总结

在本章, 为SportsStore应用构建了核心的基础设施. 现在这个应用还没有向用户展现非常多的特性. 但有一个领域模型, 它有一个由SQLServer和EFCore支持的存储库. 有一个控制器ProductController, 可以生产产品的分页列表. 如果这一章给人的感觉是做了很多准备却没有获得很多好处, 那么下一章你会感到心理平衡.
现在基本结构已经准备就绪, 可以继续添加面向客户的特性: 按类别导航, 购物车, 和付账过程.