我们要建造的程序不是一个浅显的例子。我们要创建一个坚固的,现实的程序,坚持使它成为最佳实践。与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
public
class
NinjectControllerFactory:DefaultControllerFactory
2
{
3
private
IKernel ninjectKernel;
4
5
public
NinjectControllerFactory()
6
{
7
ninjectKernel
=
new
StandardKernel();
8
AddBindings();
9
}
10
11
protected
override
IController GetControllerInstance(System.Web.Routing.RequestContext requestContext, Type controllerType)
12
{
13
return
controllerType
==
null
?
null
: (IController)ninjectKernel.Get(controllerType);
14
}
15
16
private
void
AddBindings()
17
{
18
}
现在还没有添加任何Ninject绑定,可以在需要时使用AddBindings方法。我们需要告诉Mvc,我们想要使用NinjectController类,来创建controller object。需要在Global.asax.cs文件中添加声明:
1
protected
void
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
public
class
Product {
2
public
int
ProductID {
get
;
set
; }
3
public
string
Name {
get
;
set
; }
4
public
string
Description {
get
;
set
; }
5
public
decimal
Price {
get
;
set
; }
6
public
string
Category {
get
;
set
; }
7
}
我们遵循公约,在单独的项目里定义我们的领域模型,这意味着类必须被标记为public。这个公约可以帮助我们保持model从controllers分离。
2.2 创建一个抽象Repository
我们知道我们需要一些从数据库得到Product实体的方式。我们使用repository模式,让持久化逻辑和领域模型实体相分离。目前,我们不用担心如何去实现持久化,但是我们将开始定义一个关于它的接口。
在SportsStore.Domain项目中新建一个Abstract文件夹,在它里面新建一个接口IProductsRepository。
1
public
interface
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
private
void
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
public
class
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
public
class
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
public
class
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
private
void
AddBindings() {
2
//
put additional bindings here
3
ninjectKernel.Bind
<
IProductRepository
>
().To
<
EFProductRepository
>
();
4
}
这个绑定,告诉Ninject,我们想要创建一个EFProductRepository类的实体,为IProductRepository接口的查询服务。
5 添加页码
显示一定数量的products在一个页面,用户可以一页一页地浏览全部的目录。为了做到这点,我们需要添加一个参数给controller中的List方法。
1
public
int
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
public
void
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
public
class
PagingInfo
2
{
3
public
int
TotalItems {
get
;
set
; }
4
public
int
ItemPerpage {
get
;
set
; }
5
public
int
CurrentPage {
get
;
set
; }
6
public
int
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
public
static
class
PagingHelpers
9
{
10
public
static
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> tag
15
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
public
void
Can_Generate_Page_Links()
3
{
4
HtmlHelper myHelper
=
null
;
5
6
PagingInfo pagingInfo
=
new
PagingInfo
7
{
8
CurrentPage
=
2
,
9
TotalItems
=
28
,
10
ItemPerpage
=
10
11
};
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
public
class
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
public
void
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
//
Action
16
ProductsListViewModel result
=
(ProductsListViewModel)controller.List(
2
).Model;
17
18
//
Assert
19
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/3
3
http:
//
localhost/Page2
因为使用ASP.NET routing特性,所以能很简单地实现。它允许我们在Global.asax.cs中添加一个新的路由到RegisterRoutes方法。
1
public
static
void
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}
"
,
//
带有参数的 URL
14
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)