【Pro ASP.NET MVC 3 Framework】.学习笔记.5.SportsStore一个真实的程序

我们要建造的程序不是一个浅显的例子。我们要创建一个坚固的,现实的程序,坚持使它成为最佳实践。与Web Form中拖控件不同。一开始投入MVC程序付出利息,它给我们可维护的,可扩展的,有单元测试卓越支持的构造精良的代码。一旦我们有了基本的基础设施,我们就能加快。

1 创建解决方案和项目

1.1 创建一个空白解决方案,命名为SportsStore,添加三个项目

Project Name VS Project Template Purpose
SportsStore.Domain 类库 提供域实体和逻辑。通过一个EF创建的repository,配置持久化
SportsStore.WebUI 带Razor的空白MVC3程序 扮演程序的UI,提供controllers和views
SportsStore.UnitTests Test Project 为另外两个项目提供单元测试

添加引用

Project Name Tool Dependencies Project Dependencies
SportsStore.Domain None None
SportsStore.WebUI Ninject SportsStore.Domain
SportsStore.UnitTest Ninject Moq SportsStore.Domain SportsStore.WebUI

1.2 配置DI容器

我们要用Ninject来创建controllers和handle the DI。如果要这样做,需要创建一个新类,并做一些配置上的改变。

在SportsStore.WebUI中新建一个 Infrastructure 文件夹,然后在里面创建一个 NinjectControllerFactory类。

如果要添加引用类型,可以controle+. 。

1 publicclass NinjectControllerFactory:DefaultControllerFactory 2 { 3 private IKernel ninjectKernel; 4 5 public NinjectControllerFactory() 6 { 7 ninjectKernel =new StandardKernel(); 8 AddBindings(); 9 } 10 11 protectedoverride IController GetControllerInstance(System.Web.Routing.RequestContext requestContext, Type controllerType) 12 { 13 return controllerType ==null?null : (IController)ninjectKernel.Get(controllerType); 14 } 15 16 privatevoid AddBindings() 17 { 18 }

现在还没有添加任何Ninject绑定,可以在需要时使用AddBindings方法。我们需要告诉Mvc,我们想要使用NinjectController类,来创建controller object。需要在Global.asax.cs文件中添加声明:

1 protectedvoid Application_Start() { 2 AreaRegistration.RegisterAllAreas(); 3 4 RegisterGlobalFilters(GlobalFilters.Filters); 5 RegisterRoutes(RouteTable.Routes); 6 7 ControllerBuilder.Current.SetControllerFactory(new NinjectControllerFactory()); 8 }

2 开始领域模型

2.1 在MVC程序中,几乎所有的事情都围绕着领域模型,所以它是一个完美开始。因为这是一个电子商务程序,我们需要的最明显的领域实体是Product。

在SportsStore.Domain中新建Entities文件夹,在它里面新建Product类文件。

1 publicclass Product { 2 publicint ProductID { get; set; } 3 publicstring Name { get; set; } 4 publicstring Description { get; set; } 5 publicdecimal Price { get; set; } 6 publicstring Category { get; set; } 7 }

我们遵循公约,在单独的项目里定义我们的领域模型,这意味着类必须被标记为public。这个公约可以帮助我们保持model从controllers分离。

2.2 创建一个抽象Repository

我们知道我们需要一些从数据库得到Product实体的方式。我们使用repository模式,让持久化逻辑和领域模型实体相分离。目前,我们不用担心如何去实现持久化,但是我们将开始定义一个关于它的接口。

在SportsStore.Domain项目中新建一个Abstract文件夹,在它里面新建一个接口IProductsRepository。

1 publicinterface IProductRepository { 2 3 IQueryable<Product> Products { get; } 4 }

这个接口使用了IQueryable接口,它允许获得一个Product objects的序列,而不用说数据如何获得或存在哪里。使用IProductRepository接口的类,可以获得Product objects,不需要知道任何关于数据从哪里来,它们如何分发。这是repository模式的本质。我们会在以后为该接口添加特性。

2.3 做一个Mock Repository

现在我们定义了抽象接口,我们能实现持久化机制,并hook 它 链接到数据库。为了能够开始写程序的其他部分,现在要创建一个实现了mockde的IProductRepository接口。我们将在NinjectControllerFactory类中的AddBindings方法中做这些。

1 privatevoid AddBindings() 2 { 3 Mock<IProductRepository> mock =new Mock<IProductRepository>(); 4 mock.Setup(m => m.Products).Returns(new 5 List<Product>{ 6 new Product {Name="Football",Price=25}, 7 new Product{Name="Surf board",Price=179} 8 }.AsQueryable()); 9 ninjectKernel.Bind<IProductRepository>().ToConstant(mock.Object); 10 }

VS会处理这些生命中的所有新类型的命名空间。

3 显示一个Products列表

3.1 添加Controller

创建一个空Controller

1 publicclass ProductController : Controller { 2 private IProductRepository repository; 3 4 public ProductController(IProductRepository productRepository) { 5 repository = productRepository; 6 } 7 8 public ViewResult List() { 9 return View(repository.Products); 10 } 11 }

我们添加了一个构造器,携带IProductRepository参数。这回允许Ninject为product repository,在它实例化controller类时,注入依赖。

通过传递一个Product对象的List给View方法,我们提供了框架和数据填充,给强类型的视图。

3.2 添加View

选中创建强类型视图,在Model class中,填入

1 IEnumerable<SportsStore.Domain.Entities.Product>

下拉框中不包含领域对象的枚举类型。View中的model包含一个IEnumerable<Product>意味着我们在Razor中能使用foreach创建列表。

1 @model IEnumerable<SportsStore.Domain.Entities.Product> 2 3 @{ 4 ViewBag.Title ="Products"; 5 } 6 7 @foreach(var p in Model){ 8 <div class="item"> 9 <h3>@p.Name</h3> 10 @p.Description 11 <h4>@p.Price.ToString("c")</h4> 12 </div> 13 }

将Price属性使用ToString(“c”)方法转换,它将数字类型的值按照文化设置作为货币渲染。可以在Web.config<system.web>节点中添加一个section,来改变文化设置。

1 <globalization culture="fr-FR" uiCulture="fr-FR" />

3.3 设置默认路由

在Global.asax.cs中的RegisterRoutes中,设置

1 new { controller = "Product", action = "List", id = UrlParameter.Optional }

4 准备一个数据库

我们依然在用mock IProductRepository返回的测试数据。在我们实现一个真正的repository,我们需要部署一个数据库,并用数据填充它。

我们要用SQL Server作为数据库,并使用EF访问数据库。EF是 .NET ORM框架,它允许我们使用常规的C#对象,操作关系数据库的表,列,行。

在服务器资源管理器中,在数据连接上点右键,创建新sql数据库。

新建Products表,设置ProductID列为主键,标识列,自增1 。

在表中新增一些测试数据

4.1 创建EF Context

EF的4.1版本包含一个很不错的特性,code-first。它让吗我们可以先在model中定义类,然后从这些类生成数据库。

我们使用一个已经存在的数据库,关联我们的model类,使用code-first的变种。

为SportsStore.Domain添加EF引用,下一步是创建一个context类,将我们简单的model关联到数据库。

创建Concrete文件夹,在其中创建EFDbContext类

1 publicclass EFDbContext : DbContext { 2 public DbSet<Product> Products { get; set; } 3 }

为了从code-first特性获利,我们需要创建一个类派生自System.Data.Entity.DbContext的类。这个类为每个我们想要操作的表定义了一个属性。属性名为表名,DbSet返回类型参数,指定为EF在表中持久化行的模型。在我们的例子中,属性名是Products,类型参数是Product。我们想让Product模型类型用来持久化Products表中的行。

我们需要告诉EF,如何连接到数据库。通过在SportsStore.WebUI的Web.config添加一个数据库连接字符串。

1 <configuration>2 <connectionStrings>3 <add name="EFDbContext" connectionString="Data Source=********;Initial Catalog=SportsStore;Persist Security Info=True;User ID=Sa;Password=********" providerName="System.Data.SqlClient"/>4 <!--<add name="EFDbContext" connectionString="Data Source=********;Initial Catalog=SportsStore;Integrated Security=SSPI;" providerName="System.Data.SqlClient"/>-->5 </connectionStrings>

链接字符串的名字是非常重要的,它必须和context类的名字相匹配,因为它是EF链接我们想要操作的数据库。

4.2 创建Product Repository

现在,我们有一切我们需要的,来用真实的数据实现IProductRepository类。在Concrete文件夹中添加EFProductRepository类

1 publicclass EFProductRepository:IProductRepository 2 { 3 private EFDbContext context =new EFDbContext(); 4 5 public IQueryable<Product> Products 6 { 7 get { return context.Products; } 8 } 9 }

这是我们的repository类。它实现了IProductRepository接口,使用一个EFDbContext的实例,使用EF从数据库里取回数据。你会看到使用EF特性的repository操作起来是如何简单。最后的舞台,是使用真实的数据的mock repository,替换Ninject绑定。

将SportsStore.WebUI中的NinjectControllerFactory的AddBindings方法,改为

1 privatevoid AddBindings() { 2 // put additional bindings here 3 ninjectKernel.Bind<IProductRepository>().To<EFProductRepository>(); 4 }

这个绑定,告诉Ninject,我们想要创建一个EFProductRepository类的实体,为IProductRepository接口的查询服务。

5 添加页码

显示一定数量的products在一个页面,用户可以一页一页地浏览全部的目录。为了做到这点,我们需要添加一个参数给controller中的List方法。

1 publicint pageSize =2; 2 3 public ViewResult List(int? id) 4 { 5 int page = id.HasValue ? id.Value:1; 6 return View(repository.Products 7 .OrderBy(p=>p.ProductID) 8 .Skip((page-1)*pageSize) 9 .Take(pageSize)); 10 }

此处,方法的参数必须和路由表中 controller action id处的一样,不然取不到。int?加上问号后,是可空类型。为可空类型赋默认值。

此处的方法的返回类型为ViewResult,它包含Model,后面单元测试中会用到。如果使用ActionResult,它不包含Model。

在后面,我们会将其替换为更好的分页机制。当不指定页码时,默认是第一页。Linq使得分页非常简单。在List方法中,我们从repository获得Product对象,并使用主键排序,跳过起始页之前的所有products,然后取前pageSize个products。

5.1 对分页使用单元测试

我们可以创建mock repository,当调用List方法,去请求指定页时,将它注射到ProductController类的构造函数中,对分页特性使用单元测试。然后可以比较我们得到的Product对象。

1 [TestMethod] 2 publicvoid Can_Paginate() 3 { 4 Mock<IProductRepository> mock =new Mock<IProductRepository>(); 5 mock.Setup(m => m.Products).Returns(new 6 Product[]{ 7 new Product{ProductID=1,Name="P1"}, 8 new Product{ProductID=2,Name="P2"}, 9 new Product{ProductID=3,Name="P3"}, 10 new Product{ProductID=4,Name="P4"} 11 }.AsQueryable()); 12 13 ProductController controller =new ProductController(mock.Object); 14 controller.pageSize =3; 15 16 IEnumerable<Product> result = (IEnumerable<Product>)controller.List(2).Model; 17 18 Product[] prodArray = result.ToArray(); 19 Assert.IsTrue(prodArray.Length ==1); 20 Assert.AreEqual(prodArray[0].Name, "P4"); 21 22 }

5.2 显示页面链接

5.2.1 添加视图模型

要支持HTML helper,我们要传递信息给view,如总共有多少页,当前是第几页,repository中的products总共有多少。要做到这些,最简单的方法是创建一个view model,在SportsStore.WebUI的Models文件夹中,新建PagingInfo类

1 publicclass PagingInfo 2 { 3 publicint TotalItems { get; set; } 4 publicint ItemPerpage { get; set; } 5 publicint CurrentPage { get; set; } 6 publicint TotalPages{ 7 get { return (int)Math.Ceiling((decimal)TotalItems/ItemPerpage); } 8 } 9 }

View Model不是我们领域模型的而一部分。它只是方便我们在controller和view之间传递数据的类。为了强调这点,我们把它放在SportsStore.WebUI中,让他和领域模型的类分离。

5.2.2 添加HTML Helper Method

现在我们有了视图模型,我们可以实现HTML helper方法,它被叫做PageLinks。在SportsStore.WebUI中新建HtmlHelpers文件夹,添加新的静态类PagingHelpers。

1 using System; 2 using SportsStore.WebUI.Models; 3 using System.Text; 4 using System.Web.Mvc; 5 6 namespace SportsStore.WebUI.HtmlHelpers 7 { 8 publicstaticclass PagingHelpers 9 { 10 publicstatic MvcHtmlString PageLinks(this System.Web.Mvc.HtmlHelper html,PagingInfo pagingInfo,Func<int,string> pageUrl) 11 { 12 StringBuilder result =new StringBuilder(); 13 for (int i =1; i < pagingInfo.TotalPages;i++ ) { 14 TagBuilder tag =new TagBuilder("a");//Construct an <a> tag15 tag.MergeAttribute("href", pageUrl(i)); 16 tag.InnerHtml = i.ToString(); 17 if(i==pagingInfo.CurrentPage){ 18 tag.AddCssClass("selected"); 19 } 20 result.Append(tag.ToString()); 21 } 22 return MvcHtmlString.Create(result.ToString()); 23 } 24 } 25 }

PageLinks扩展方法,使用PagingInfo对象提供的信息,生成一组page links的HTML。Func参数,提供了传递委托的能力,用来生成显示在其他页面上的链接。

5.2.3 对生成的page links使用单元测试

为测试PageLinks helper方法,我们使用测试数据,调用它,并将它产生的结果,和我们期待的HTML作比较。

1 [TestMethod] 2 publicvoid Can_Generate_Page_Links() 3 { 4 HtmlHelper myHelper =null; 5 6 PagingInfo pagingInfo =new PagingInfo 7 { 8 CurrentPage=2, 9 TotalItems=28, 10 ItemPerpage=1011 }; 12 13 Func<int, string> pageUrlDelegate = i =>"Page"+ i; 14 15 MvcHtmlString result = myHelper.PageLinks(pagingInfo, pageUrlDelegate); 16 17 Assert.AreEqual(result.ToString(),@"<a href=""Page1"">1</a><a class=""selected"" href=""Page2"">2</a><a href=""Page3"">3</a>"); 18 }

测试正式了helper方法输出包含两个引号的字符串值。C#能完美地胜任处理这样的字符串,只要我们记得在字符串前加@,并使用两组双引号,来替代一组双引号。我们也必须记住不能打破字符串到单独的行,除非我们比较的字符串也是同样的破碎。例如,我们在test方法中包裹的字符串有两行,因为页面的宽度太窄。偶们没有添加新航符号,如果我们这样做,测试会失败。

在Razor视图中,要引用扩展方法,我们必须在Web.config中添加配置,或者在view中直接添加@using声明。Razor MVC项目里有两个Web.config文件:主文件,在根目录下。View目录下的是Veiw-Spacific。这里我们要改变View目录下的配置文件。

1 <add namespace="SportsStore.WebUI.HtmlHelpers"/>

每个Razor要用到的命名空间,都需要通过这种方式,或直接在view中使用@using 声明。

5.2,4 添加视图模型数据

我们还没有完全准备好使用HTML helper方法。我们也需要给View提供PagingInfo视图模型类的实例。为了做到这点,我们可以使用View Data或View Bag特性,但是我们需要将它转换为适当的类型。

我们更想将从controller发送到view的数据的所有数据,包装成一个单独的视图模型类。为了做到这点,添加一个ProductListViewModel类到Models文件夹。

1 publicclass ProductsListViewModel 2 { 3 public IEnumerable<Product> Products { get; set; } 4 public PagingInfo PagingInfo { get; set; } 5 }

现在偶们需要更新List方法,使用ProductsListViewModel类,将Products要显示的细节,和分页的细节,来提供给视图。

1 public ViewResult List(int? id) 2 { 3 int page = id.HasValue ? id.Value : 1; 4 ProductsListViewModel viewModel =new ProductsListViewModel 5 { 6 Products=repository.Products 7 .OrderBy(p=>p.ProductID) 8 .Skip((page-1)*pageSize) 9 .Take(pageSize), 10 PagingInfo=new PagingInfo 11 { 12 CurrentPage=page, 13 ItemPerpage=pageSize, 14 TotalItems=repository.Products.Count() 15 } 16 }; 17 return View(viewModel); 18 }

这个改变,将传递ProductsListViewModel对象作为模型数据,发送给view。

5.2.5 分页模型视图数据的单元测试

1 [TestMethod] 2 publicvoid Can_Send_Pagination_View_Model() 3 { 4 Mock<IProductRepository> mock =new Mock<IProductRepository>(); 5 mock.Setup(m=>m.Products).Returns(new Product[] { 6 new Product{ProductID=1,Name="P1"}, 7 new Product{ProductID=2,Name="P2"}, 8 new Product{ProductID=3,Name="P3"}, 9 new Product{ProductID=4,Name="P4"} 10 }.AsQueryable()); 11 12 ProductController controller =new ProductController(mock.Object); 13 controller.pageSize =3; 14 15 //Action16 ProductsListViewModel result = (ProductsListViewModel)controller.List(2).Model; 17 18 //Assert19 PagingInfo pageInfo = result.PagingInfo; 20 Assert.AreEqual(pageInfo.CurrentPage, 2); 21 Assert.AreEqual(pageInfo.ItemPerpage, 3); 22 Assert.AreEqual(pageInfo.TotalItems, 4); 23 Assert.AreEqual(pageInfo.TotalPages, 2); 24 }

因为List action方法的返回的模型变了,所以需要对Can_Paginate进行修改。

1 // Action 2 ProductsListViewModel result = (ProductsListViewModel)controller.List(2).Model; 3 4 // Assert 5 Product[] prodArray = result.Products.ToArray();

现在需要修改List.cshtml,来处理新的视图模型类型。

1 @model SportsStore.WebUI.Models.ProductsListViewModel 2 3 @foreach(var p in Model.Products) 4 5 <div class="pager">6 @Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new { id=x})) 7 </div>

5.2.6 为什么不直接使用gridview

如果用过ASP.NET,可以使用Web Form的GridView控件,直接关联到Products数据库表上。

首先,我们建立了一个坚固的,可维护的建筑,包括适当的关注点分离。不像简单地使用GridView,我们没有直接将UI和数据库组合在一起,这种方式能够快速得到结果,但随着时间的推移,会痛苦和不幸。

第二,偶们创建了单元测试,它允许我们用原生的方法,验证程序的行为,这在Web Form的GridView控件中是不可能的。

最后,记住这些章节已经创建了程序的底层基础设施。我们只需要定义和实现repository一次,我们就能快速且容易地创建和测试新特性。

5.3 改进URLs

我们依然使用传递给夫妻的查询字符串现在在page links中。我们能做的更好,指定一个URLs组成的方案。显示效果像最下面那样

1 http://localhost/?page=2 2 http://localhost/Product/List/33 http://localhost/Page2

因为使用ASP.NET routing特性,所以能很简单地实现。它允许我们在Global.asax.cs中添加一个新的路由到RegisterRoutes方法。

1 publicstaticvoid RegisterRoutes(RouteCollection routes) 2 { 3 routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); 4 5 routes.MapRoute( 6 null, 7 "Page{id}", 8 new { controller ="Product", action ="List" } 9 ); 10 11 routes.MapRoute( 12 "Default", // 路由名称13 "{controller}/{action}/{id}", // 带有参数的 URL14 new { controller ="Product", action ="List", id = UrlParameter.Optional } // 参数默认值15 ); 16 17 }

可以发现,Url.Action方法生成的链接也变成了以上格式。

6 Content的样式

在_Layout.cshtml文件中,新增如下代码

1 <div id="header">2 <div class="title">SPORTS STORE</div>3 </div>4 <div id="categories">5 Will put something useful here later 6 </div>7 <div id="content">8 @RenderBody() 9 </div>

Razor不能自动地识别 ~ ,将其作为程序的根。所以我们要使用helper的@Url.Content方法。

6.1 创建局部视图

作本章的结束,我们将重构程序,以简化List.cshtml。我们将创建局部视图,它是嵌入在其他view中的片段。局部视图被包含在它自己的文件中,可以通过view在读使用,帮助我们减少复制,尤其是在你需要在很多地方渲染相同类型的数据。

要添加局部视图,在/View/Shared文件夹上右键,选择新建视图ProductSummary,选择Product类作为模型,勾上作为局部视图的选项。点击添加后,在Views/Shared/ProductSummary.cshtml。局部视图和常规视图很相似,但是当我们访问它时,它渲染了一个HTML片段,而不是整个HTML文档。

1 @model SportsStore.Domain.Entities.Product 2 3 <div class="item">4 <h3>@Model.Name</h3>5 @Model.Description 6 <h4>@Model.Price.ToString("c")</h4>7 </div>

然后使用局部视图更新List.cshtm。

1 @foreach (var p in Model.Products) { 2 Html.RenderPartial("ProductSummary", p); 3 }

调用Html.RenderPartial helper方法,参数是局部视图的名字和视图模型对象。

RenderPartial方法不像其他helper method返回HTML标记。它将content直接写入response流中。这是我们必须以完整的C#行,使用分号,调用它的原因。这是稍微更有效率,比从局部视图缓存HTML渲染。如果你想要坚持始终如一的语法,可以使用Html.Partial方法,它完全和RenderPartial方法一样,但是能不加分号使用。像这样切换到局部视图是个很好的实践。

7 总结

现在偶们有了领域模型的开始,使用Sql server和EF返回的Product repository。我们有了一个简单的controller,它能产生products的分页,我们设置了DI和一个简洁而友好的URL方案。

下章,我们会有完全面向客户的特性:分类导航,购物车,结账流程。

关于CSS的书籍

  • Pro CSS and HTML Design Patterns by Michael Bowers (Apress, 2007)
  • Beginning HTML with CSS and HTML by David Schultz and Craig Cook (Apress, 2007)
原文地址:https://www.cnblogs.com/msdynax/p/3287727.html