我们要建造的程序不是一个浅显的例子。我们要创建一个坚固的,现实的程序,坚持使它成为最佳实践。与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)