ASP.NET MVC5(五)SportsStore项目

作者 Zhendong Ho 日期 2021-04-05
ASP.NET MVC5(五)SportsStore项目

开始

该应用程序的名称为SportStore,它遵循在线商店所采取的经典方式。包含以下功能。

  • 产品分类。客户可以通过分类和页面进行浏览。
  • 商品购物车。客户可以添加或删除商品。
  • 结算页面。客户输入其邮寄地址的细节。
  • 管理区。包含管理产品分类所需的CRUD功能,并且只有管理员才能操作。

创建VS解决方案和项目。

创建一个解决方案,且包含有3个项目域模型项目MVC应用程序项目单元测试项目

打开VS,创建空解决方案,名称为SportsStore。然后添加3个项目。

项目名称 VS项目模板 目的
SportsStore.Domain 类库 存放域实体与逻辑,通过EF创建的存储库来建立持久化
SportsStore.WebUI ASP.NET MVC Web应用程序(空模板) 存放控制器与视图,充当SportsStore应用程序的UI
SportsStore.UnitTests 测试项目 存放用于上述两个项目的单元测试

创建完成3个项目后,做以下几件事。

  • 删除SportsStore.Domain项目中的Class1.cs文件。
  • 把SportsStore.WebUI项目,设置为启动项目
  • 打开SportsStore.WebUI项目的属性页,勾选特定页选项,但文本框不需要输入值。这样,启动调试后,将直接请求应用程序的根URL。

安装工具包

打开Nuget命令行窗口。输入以下命令。

  • Install-Package Ninject -version 3.0.1.10 -projectname SportsStore.WebUI
  • Install-Package Ninject.Web.Common -version 3.0.0.7 -projectname SportsStore.WebUI
  • Install-Package Ninject.MVC3 -version 3.0.0.6 -projectname SportsStore.WebUI
  • Install-Package Ninject -version 3.0.1.10 -projectname SportsStore.UnitTests
  • Install-Package Ninject.Web.Common -version 3.0.0.7 -projectname SportsStore.UnitTests
  • Install-Package Ninject.MVC3 -version 3.0.0.6 -projectname SportsStore.UnitTests
  • Install-Package Moq -version 4.1.1309.1617 -projectname SportsStore.WebUI
  • Install-Package Moq -version 4.1.1309.1617 -projectname SportsStore.UnitTests
  • Install-Package Microsoft.Aspnet.Mvc -version 5.0.0 -projectname SportsStore.Domain
  • Install-Package Microsoft.Aspnet.Mvc -version 5.0.0 -projectname SportsStore.UnitTests

添加项目之间的引用

项目名 解决方案依赖项 程序集引用
SportsStore.Domain None System.ComponentModel.DataAnnotations
SportsStore.WebUI SportsStore.Domain None
SportsStore.UnitTests SportsStore.Domain
SportsStore.WebUI
System.Web
Microsoft.CSharp

设置DI容器

SportsStore.WebUI项目添加Infrastructure文件夹,在其中添加NinjectDependencyResolver.cs文件。

public class NinjectDependencyResolver : IDependencyResolver
{
private IKernel kernel;

public NinjectDependencyResolver(IKernel kernelParamm)
{
kernel = kernelParamm;
AddBindings();
}

public object GetService(Type serviceType)
{
return kernel.TryGet(serviceType);
}

public IEnumerable<object> GetServices(Type serviceType)
{
return kernel.GetAll(serviceType);
}

private void AddBindings()
{
// 将绑定放在这里
}
}

修改App_Start文件夹中的NinjectWebCommon.cs文件,集成Ninject。

/// <summary>
/// Load your modules or register your services here!
/// </summary>
/// <param name="kernel">The kernel.</param>
private static void RegisterServices(IKernel kernel)
{
System.Web.Mvc.DependencyResolver.SetResolver(new Infrastructure.NinjectDependencyResolver(kernel));
}

从域模型开始

SportsStore.Domain项目中,添加Entities文件夹,添加Product.cs文件。

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; }
}

创建抽象存储库

存储库模式用于从持久化数据库中存储和接收数据。

SportsStore.Domain项目中,创建Abstract文件夹,添加IProductsRepository.cs接口。

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

IQueryable<T>接口继承了IEnumerable<T>接口。一个类可以依靠IProductRepository接口获取Product对象,而不必知道这些对象从哪儿来,也不必知道接口如何实现,这就是存储库模式的本质。

创建模仿存储库

创建IProductRepository接口模仿实现,直到使用数据库时,再替换这些存储库模仿实现。

在SportsStore.WebUI项目的NinjectDependencyResolver类AddBindings方法定义模仿实现,并绑定IProductRepository接口

private void AddBindings()
{
// 将绑定放在这里
Mock<IProductRepository> mock = new Mock<IProductRepository>();
mock.Setup(m => m.Products).Returns(new List<Product>
{
new Product { Name = "Football", Price = 25 },
new Product { Name = "Surf board", Price = 179 },
new Product { Name = "Running shoes", Price = 95 }
});
kernel.Bind<IProductRepository>().ToConstant(mock.Object);
}

ToConstant方法设置了Ninject的作用域。每次接收到IProductRepository接口实现的请求时,返回的都是同样的模仿对象

显示产品列表

添加控制器

SportsStore.WebUI项目中,添加ProductController控制器。其中构造器中声明了一个对IProductRepository接口的依赖项,并添加List动作方法

public class ProductController : Controller
{
private IProductRepository repository;

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

public ViewResult List()
{
return View(repository.Products);
}
}

添加布局、视图起始文件及视图

右键List动作方法,添加List视图,选择空模板,选择Product模型类,选择使用布局页

修改_Layout.cshtml文件代码如下。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewBag.Title</title>
</head>
<body>
@RenderBody()
</body>
</html>

修改List视图代码如下。

@using SportsStore.Domain.Entities
@model IEnumerable<Product>

@{
ViewBag.Title = "Products";
}

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

注意,ToString(“c”)方法,将Price属性转换成一个字符串,根据服务器的语言,渲染成货币形式。也可以修改服务器的语言设置,只要把<globalization culture=”fr-FR” uiCulture=”fr-FR” />添加到web.config<system.web>结点即可。

设置默认路由

应该将应用程序根URL的请求发送给ProductControllerList动作方法。修改RouteConfig.cs文件,将Home改成Product,将Index改成List。

public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Product", action = "List", id = UrlParameter.Optional }
);
}

运行应用程序

页面正常显示。

准备数据库

数据库使用SQLServer,并用Entity Framework来访问数据库。

创建数据库

打开SSMS,创建数据库SportsStore,创建表Products,并插入数据。

use SportsStore

create table Products
(
[ProductID] int not null primary key identity,
[Name] nvarchar(100) not null,
[Description] nvarchar(500) not null,
[Category] nvarchar(50) not null,
[Price] decimal(16, 2) not null
)

insert into Products(Name, Description, Category, Price)
values('Kakak', 'A boat for one person', 'Watersports', 275),
('Lifejacket', 'Protective and fashionable', 'Watersports', 48.95),
('Soccer Ball', 'FIFA-approved size and weight', 'Soccer', 19.50),
('Corner Flags', 'Give your playing field a professional touch', 'Soccer', 34.95),
('Stadium', 'Flat-packed 35,000-seat stadium', 'Soccer', 79500.00),
('Thinking Cap', 'Improve your brain efficiency', 'Chess', 16.00),
('Unsteady Chair', 'Secretly give your opponent a disadvantage', 'Chess', 29.95),
('Human Chess Board', 'A fun game for the family', 'Chess', 75.00),
('Bling-Bling King', 'Gold-plated, diamond-studded King', 'Chess', 1200)

创建Entity Framework上下文

安装EF

打开Nuget命令行,输入命令安装EF。

  • Install-Package EntityFramework -projectname SportsStore.Domain
  • Install-Package EntityFramework -projectname SportsStore.WebUI

创建上下文类

创建上下文类,以便模型与数据库关联起来。在SportsStore.Domain项目中创建Concrete文件夹,并添加EFDbContext.cs文件。

public class EFDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
}

注意

  • 为了利用code-first特性,需要创建一个派生于System.Data.Entity.DbContext的类,该类会为数据库中的每个表自动定义一个属性
  • 属性名称为数据表名。而DbSet结果的类型参数为模型类型

配置EF连接数据库

SportsStore.WebUI项目中,修改web.config文件。该连接字符串的名称与这个上下文类的名称相同。

<connectionStrings>
<add name="EFDbContext" connectionString="Data Source=.;Initial Catalog=SportsStore;Integrated Security=True" providerName="System.Data.SqlClient"/>
</connectionStrings>

创建Product存储库

Concrete文件夹下,添加EFProductRepository.cs文件。

public class EFProductRepository : IProductRepository
{
private EFDbContext context = new EFDbContext();

public IEnumerable<Product> Products
{
get { return context.Products; }
}
}

注意:存储库类实现了IProductRepository接口,并使用一个EFDbContext实例,以便用EF接收数据库的数据。

修改NinjectDependencyResolver.cs文件中的绑定,用实际存储库绑定替换之前的模仿存储库

private void AddBindings()
{
kernel.Bind<IProductRepository>().To<EFProductRepository>();
}

运行项目,可以看到应用程序从数据库,而不是从模仿存储库得到了产品数据。

添加分页

分页功能

ProductController中的List动作方法添加一个分页参数,实现分页功能。

public class ProductController : Controller
{
public int PageSize = 4; // 每页显示的条目数

// ...

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

单元测试:分页

在单元测试项目的UnitTest1.cs文件创建单元测试。

[TestClass]
public class UnitTest1
{
[TestMethod]
public void Can_Paginate()
{
// 准备
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" }
});
ProductController controller = new ProductController(mock.Object);
controller.PageSize = 3;

// 动作
IEnumerable<Product> result = (IEnumerable<Product>)controller.List(2).Model; // 调用Model属性获取控制器返回的数据

// 断言
Product[] prodArray = result.ToArray();
Assert.IsTrue(prodArray.Length == 2);
Assert.AreEqual(prodArray[0].Name, "P4");
Assert.AreEqual(prodArray[1].Name, "P5");
}
}

注意:获取控制器返回的数据很容易。在结果上调用Model属性,就可以得到在List方法中生成的IEnumerable<Product>序列

显示页面链接

运行项目,通过修改https://localhost:44328/?page=1链接中的page参数来切换分页。

这样很不方便。因此,需要在每个产品列表的底部渲染某种页面链接,让客户能够在页面之间进行导航。

添加视图模型

在SportsStore.WebUI项目中的Models文件夹,添加PagingInfo.cs文件。

public class PagingInfo
{
/// <summary>
/// 存储库中的产品总数
/// </summary>
public int TotalItems { get; set; }
/// <summary>
/// 每页产品数
/// </summary>
public int ItemsPerPage { get; set; }
/// <summary>
/// 当前页
/// </summary>
public int CurrentPage { get; set; }
/// <summary>
/// 可用页面数
/// </summary>
public int TotalPages
{
get { return (int)Math.Ceiling((decimal)TotalItems / ItemsPerPage); }
}
}

注意视图模型不属于域模型,它只是一种便于在控制器与视图之间传递数据的类。因此,把PagingInfo类放在MVC框架项目的Models文件夹

添加HTML辅助器方法

在SportsStore.WebUI项目中,创建HtmlHelpers文件夹,并添加PagingHelpers.cs文件。

public static class PagingHelpers
{
public static MvcHtmlString PageLinks(this HtmlHelper html, PagingInfo pagingInfo, Func<int, string> pageUrl)
{
StringBuilder result = new StringBuilder();
for (int i = 1; i <= pagingInfo.TotalPages; i++)
{
TagBuilder tag = new TagBuilder("a");
tag.MergeAttribute("href", pageUrl(i));
tag.InnerHtml = i.ToString();
if (i == pagingInfo.CurrentPage)
{
tag.AddCssClass("selected");
tag.AddCssClass("btn-primary");
}
tag.AddCssClass("btn btn-default");
result.Append(tag.ToString());
}
return MvcHtmlString.Create(result.ToString());
}
}

注意PageLinks扩展方法是用PagingInfo对象提供的信息生成一组页面链接的HTML标记

单元测试:创建页面链接

在单元测试中,新增测试方法,测试PageLinks辅助器方法

[TestMethod]
public void Can_Generate_Page_Links()
{
// 准备 - 定义一个HTML辅助器,这是必须的,目的是运用扩展方法
HtmlHelper myHelper = null;

// 准备 - 创建PagingInfo数据
PagingInfo pagingInfo = new PagingInfo
{
CurrentPage = 2,
TotalItems = 28,
ItemsPerPage = 10
};

// 准备 - 使用lambda表达式建立委托
Func<int, string> pageUrlDelegate = i => "Page" + i;

// 动作
MvcHtmlString result = myHelper.PageLinks(pagingInfo, pageUrlDelegate);

// 断言
Assert.AreEqual(@"<a class=""btn btn-default"" href=""Page1"">1</a>"
+ @"<a class=""btn btn-default btn-primary selected"" href=""Page2"">2</a>"
+ @"<a class=""btn btn-default"" href=""Page3"">3</a>", result.ToString());
}

添加视图的模型数据

在SportsStore.WebUI项目的Models文件夹中,添加ProductsListViewModel.cs文件。

public class ProductsListViewModel
{
public IEnumerable<Product> Products { get; set; }
public PagingInfo PagingInfo { get; set; }
}

修改ProductController中的List动作方法,将ProductsListViewModel类传给视图。

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

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

为了确保控制器向视图发送正确的分页数据,在单元测试添加新的测试方法。

[TestMethod]
public void Can_Send_Pagination_View_Model()
{
// 准备
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" }
});

// 准备
ProductController controller = new ProductController(mock.Object);
controller.PageSize = 3;

// 动作
ProductsListViewModel result = (ProductsListViewModel)controller.List(2).Model;

// 断言
PagingInfo pageInfo = result.PagingInfo;
Assert.AreEqual(pageInfo.CurrentPage, 2);
Assert.AreEqual(pageInfo.ItemsPerPage, 3);
Assert.AreEqual(pageInfo.TotalItems, 5);
Assert.AreEqual(pageInfo.TotalPages, 2);
}

PS:由于修改了List动作方法传给视图的模型,因此对应的测试方法Can_Paginate也需要做出相应修改。并且更新List视图中的@model

显示页面链接

List视图中调用HTML辅助器方法

@using SportsStore.WebUI.HtmlHelpers
@model SportsStore.WebUI.Models.ProductsListViewModel

@{
ViewBag.Title = "Products";
}

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

<div>
@Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new { page = x }))
</div>

注意:只有当包含扩展方法的命名空间在范围内时,其中的扩展方法才是可用的。

  • 一种方法是在视图文件中使用@using语句,如List视图代码中的第一行。
  • 另一种方法是在Views/Web.config配置文件中,添加HTML辅助器方法的命名空间,这样就可以去掉视图中的@using。
<system.web.webPages.razor>
<host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.2.7.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
<pages pageBaseType="System.Web.Mvc.WebViewPage">
<namespaces>
<add namespace="System.Web.Mvc" />
<add namespace="System.Web.Mvc.Ajax" />
<add namespace="System.Web.Mvc.Html" />
<add namespace="System.Web.Routing" />
<add namespace="SportsStore.WebUI" />
<add namespace="SportsStore.WebUI.HtmlHelpers" />
</namespaces>
</pages>
</system.web.webPages.razor>

运行应用程序,可以看到,产品列表下方有分页链接

改进URL

目前的链接使用的是查询字符串形式,如:http://localhost/?page=2。
可以根据可组合URL模式创建一种对用户有意义的URL形式。如:http://localhost/Page2。

修改RouteConfig.cs文件的RegisterRoutes方法,添加一条新的路由。

public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

// 该路由必须在Default路由之前,该路由优先于Default路由
routes.MapRoute(
name: null,
url: "Page{page}",
defaults: new { controller = "Product", action = "List" }
);

routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new { controller = "Product", action = "List", id = UrlParameter.Optional }
);
}
}

运行应用程序,并导航到一个页面,可以看到新的URL方案已生效。

设置内容样式

安装Bootstrap包

输入Nuget命令:Install-Package -version 3.0.0 bootstrap -projectname SportsStore.WebUI

在布局中运用Bootstrap样式

修改_Layout.cshtml文件,添加Bootstrap样式。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="~/Content/bootstrap.css" rel="stylesheet" />
<link href="~/Content/bootstrap-theme.css" rel="stylesheet" />
<title>@ViewBag.Title</title>
</head>
<body>
<div class="navbar navbar-inverse" role="navigation">
<a class="navbar-brand" href="#">SPORTS STORE</a>
</div>
<div class="row panel">
<div id="categories" class="col-xs-3">
Put something useful here later
</div>
<div class="col-xs-8">
@RenderBody()
</div>
</div>
</body>
</html>

修改List.cshtml文件。

@model SportsStore.WebUI.Models.ProductsListViewModel

@{
ViewBag.Title = "Products";
}

@foreach (var p in Model.Products)
{
<div class="well">
<h3>
<strong>@p.Name</strong>
<span class="pull-right label label-primary">@p.Price.ToString("c")</span>
</h3>
<span class="lead">@p.Description</span>
</div>
}

<div class="btn-group pull-right">
@Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new { page = x }))
</div>

创建分部视图

分部视图是嵌入到另一个视图中的一个内容片段,而不是一个模板。它是一种自包含的文件,且可以跨视图重用,有助于减少重复

右键SportsStore.WebUI项目的/Views/Shared文件夹,点击添加视图。选择空模板,模型类选择Product,并勾选创建为分部视图,命名为ProductSummary

ProductSummary分部视图中添加标记。

@model SportsStore.Domain.Entities.Product

<div class="well">
<h3>
<strong>@Model.Name</strong>
<span class="pull-right label label-primary">@Model.Price.ToString("c")</span>
</h3>
<span class="lead">@Model.Description</span>
</div>

修改List视图,使用ProductSummary分部视图去掉foreach循环中的重复标记。

@model SportsStore.WebUI.Models.ProductsListViewModel

@{
ViewBag.Title = "Products";
}

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

<div class="btn-group pull-right">
@Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new { page = x }))
</div>