3 创建购物车
每个商品旁边都要显示Add to cart按钮。点击按钮后,会显示客户已经选中的商品的摘要,包括总金额。在购物车里,用户可以点击继续购物按钮返回product目录。也可以点击Checkout now按钮,完成订单和购物会话。
3.1 定义Cart Entity
购物车是程序业务域的一部分,在我们的领域模型中创建实体。添加一个Cart类到Entities文件夹。
1
namespace
SportsStore.Domain.Entities
2
{
3
public
class
Cart
4
{
5
private
List
<
CartLine
>
lineCollection
=
new
List
<
CartLine
>
();
6
7
public
void
AddItem(Product product,
int
quantity)
8
{
9
//
检查购物车中是否已经有该产品
10
CartLine line
=
lineCollection
11
.Where(p
=>
p.Product.ProductID
==
product.ProductID)
12
.FirstOrDefault();
13
14
if
(line
==
null
)
15
{
16
lineCollection.Add(
new
CartLine { Product
=
product, Quantity
=
quantity });
17
}
18
else
19
{
20
line.Quantity
+=
quantity;
21
}
22
}
23
24
public
void
RemoveLine(Product product)
25
{
26
lineCollection.RemoveAll(l
=>
l.Product.ProductID
==
product.ProductID);
27
}
28
29
public
decimal
ComputeTotalValue()
30
{
31
return
lineCollection.Sum(e
=>
e.Product.Price
*
e.Quantity);
32
}
33
34
public
void
Clear()
35
{
36
lineCollection.Clear();
37
}
38
39
public
IEnumerable
<
CartLine
>
Lines
40
{
41
get
{
return
lineCollection; }
42
}
43
}
44
public
class
CartLine
45
{
46
public
Product Product {
get
;
set
; }
47
public
int
Quantity {
get
;
set
; }
48
}
49
}
50
购物车类使用CartLine,代表用户选中的一个商品。定义了添加、移除、计算合计、清空的方法。我们也提供了一个属性,返回IEnumerble<CartLine。
3.1.1 测试购物车单元测试
Cart类相对简单,但有一些非常重要的行为,我们必须确保工作正常。一个功能不良的购物车会破坏程序的整体。偶们对这些特性一个一个测试。
第一个要测试的行为,是将添加货物到购物车。如果该商品是第一次被加到购物车,我们需要一个新的CartLine。
1
[TestMethod]
2
public
void
Can_Add_New_Lines()
3
{
4
//
Arrange - create some test products
5
Product p1
=
new
Product { ProductID
=
1
, Name
=
"
P1
"
};
6
Product p2
=
new
Product { ProductID
=
2
, Name
=
"
P2
"
};
7
8
//
Arrange - create a new cart
9
Cart target
=
new
Cart();
10
11
//
Act
12
target.AddItem(p1,
1
);
13
target.AddItem(p2,
1
);
14
CartLine[] results
=
target.Lines.ToArray();
15
16
//
Assert
17
Assert.AreEqual(results.Length,
2
);
18
Assert.AreEqual(results[
0
].Product, p1);
19
Assert.AreEqual(results[
1
].Product, p2);
20
}
如果客户已经添加过该商品,我们需要增加相应CartLine的数量,而不是创建一个新的。
1
[TestMethod]
2
public
void
Can_Add_Quantiy_For_Existing_Lines()
3
{
4
//
Arrange - create some test products
5
Product p1
=
new
Product { ProductID
=
1
, Name
=
"
P1
"
};
6
Product p2
=
new
Product { ProductID
=
2
, Name
=
"
P2
"
};
7
8
//
Arrange - create a new cart
9
Cart target
=
new
Cart();
10
11
//
Act
12
target.AddItem(p1,
1
);
13
target.AddItem(p2,
1
);
14
target.AddItem(p1,
10
);
15
CartLine[] results
=
target.Lines.OrderBy(c
=>
c.Product.ProductID).ToArray();
16
17
//
Assert
18
Assert.AreEqual(results.Length,
2
);
19
Assert.AreEqual(results[
0
].Quantity,
11
);
20
Assert.AreEqual(results[
1
].Quantity,
1
);
21
}
我们也需要检查移除商品的功能。
1
[TestMethod]
2
public
void
Can_Remove_Lines()
3
{
4
//
Arrange - create some test products
5
Product p1
=
new
Product { ProductID
=
1
, Name
=
"
P1
"
};
6
Product p2
=
new
Product { ProductID
=
2
, Name
=
"
P2
"
};
7
Product p3
=
new
Product { ProductID
=
3
, Name
=
"
P3
"
};
8
9
//
Arrange - create a new cart
10
Cart target
=
new
Cart();
11
12
//
Arrange - add some products to the cart
13
target.AddItem(p1,
1
);
14
target.AddItem(p2,
3
);
15
target.AddItem(p3,
5
);
16
target.AddItem(p2,
1
);
17
18
//
Act
19
target.RemoveLine(p2);
20
21
//
Assert
22
Assert.AreEqual(target.Lines.Where(c
=>
c.Product
==
p2).Count(),
0
);
23
Assert.AreEqual(target.Lines.Count(),
2
);
24
}
计算总金额的功能:
1
[TestMethod]
2
public
void
Calculate_Cart_Total()
3
{
4
//
Arrange - create some test products
5
Product p1
=
new
Product { ProductID
=
1
, Name
=
"
P1
"
,Price
=
100M};
6
Product p2
=
new
Product { ProductID
=
2
, Name
=
"
P2
"
,Price
=
50M};
7
8
//
Arrange - create a new cart
9
Cart target
=
new
Cart();
10
11
//
Act
12
target.AddItem(p1,
1
);
13
target.AddItem(p2,
1
);
14
target.AddItem(p1,
3
);
15
decimal
result
=
target.ComputeTotalValue();
16
17
//
Assert
18
Assert.AreEqual(result, 450M);
19
}
最后测试的是清空功能
1
[TestMethod]
2
public
void
Can_Clear_Contents()
3
{
4
//
Arrange - create some test products
5
Product p1
=
new
Product { ProductID
=
1
, Name
=
"
P1
"
,Price
=
100M};
6
Product p2
=
new
Product { ProductID
=
2
, Name
=
"
P2
"
,Price
=
50M};
7
8
//
Arrange - create a new cart
9
Cart target
=
new
Cart();
10
11
//
Act
12
target.AddItem(p1,
1
);
13
target.AddItem(p2,
1
);
14
15
target.Clear();
16
17
//
Assert
18
Assert.AreEqual(target.Lines.Count(),
0
);
19
}
3.2 Add to Cart按钮
1
@model SportsStore.Domain.Entities.Product
2
3
<
div
class
=
"
item
"
>
4
<
h3
>
@Model.Name
</
h3
>
5
@Model.Description
6
7
@using(Html.BeginForm(
"
AddToCart
"
,
"
Cart
"
)){
8
@Html.HiddenFor(x
=>
x.ProductID)
9
@Html.Hidden(
"
returnUrl
"
,Request.Url.PathAndQuery)
10
<
input type
=
"
submit
"
value
=
"
+ Add to cart
"
/>
11
}
12
13
<
h4
>
@Model.Price.ToString(
"
c
"
)
</
h4
>
14
</
div
>
改变ProductSummary.cshtml局部视图。当表单被提交时,会提交到Cart controller中的AddToCart action方法。
默认地,BeginForm helper方法创建一个表单,使用HTTP POST方法。可以变为GET方法。
3.2.1 在同一个地方创建多个HTML FORMS
使用HTML.BeginForm helper在每个商品列表,意味着每个Add to cart按钮会被渲染在相互分隔的自己的HTML from元素中。在ASP.NET Web Forms中,一个页面限制只有一个form。ASP.NET MVC不限制每个页面的form数量,你要多少可以加多少。
不同form返回到相同的controller方法,伴随着不同的参数值,这是一个很好而且简单的方法,来处理button点击。
3.3 实现Cart Controller
我们需要创建一个CartController,来处理Add to cart按钮的点击。
1
public
class
CartController : Controller
2
{
3
//
4
//
GET: /Cart/
5
private
IProductRepository repository;
6
7
public
CartController(IProductRepository repo)
8
{
9
repository
=
repo;
10
}
11
12
public
RedirectToRouteResult AddToCart(
int
productId,
string
returnUrl)
13
{
14
Product product
=
repository.Products.FirstOrDefault(p
=>
p.ProductID
==
productId);
15
16
if
(product
!=
null
)
17
{
18
GetCart().AddItem(product,
1
);
19
}
20
return
RedirectToAction(
"
Index
"
,
new
{ returnUrl });
21
}
22
23
public
RedirectToRouteResult RemoveFromCart(
int
productId,
string
returnUrl)
24
{
25
Product product
=
repository.Products.FirstOrDefault(p
=>
p.ProductID
==
productId);
26
27
if
(product
!=
null
){
28
GetCart().RemoveLine(product);
29
}
30
return
RedirectToAction(
"
Index
"
,
new
{ returnUrl });
31
32
}
33
34
private
Cart GetCart()
35
{
36
Cart cart
=
(Cart)Session[
"
Cart
"
];
37
if
(cart
==
null
){
38
cart
=
new
Cart();
39
Session[
"
Cart
"
]
=
cart;
40
}
41
return
cart;
42
}
43
}
这里有几个点。第一个是ASP.NET session状态特性,存储并检索Cart对象,这是GetCart方法的目的。ASP.NET有很好的session特性,使用cookis或URL重写用户的关联请求,从form一个单一浏览session。相关的的特性是session状态,它允许我们用session关联数据。这是一个适合偶们Cart类的想法。偶们像让每个用户有自己的购物车,我们想让购物车固定在不同的请求。数据关联到session,session过期时会删除。这意味着我们不需要管理Cart类的存储或生命周期。
1
Session[
"
Cart
"
]
=
cart;
//
在Session对象上设置一个key的value
2
Cart cart
=
(Cart)Session[
"
Cart
"
];
//
检索对象,读取key
Session装填对象,默认存储在Asp.net服务器的内存中。你可以配置一个不同的存储路径,包括使用Sql数据库。
在AddToCart和RemoveFromCart方法中,我们使用参数名匹配HTML form中输入的元素。这允许MVC框架关联POST变量传递来的参数,意味着我们不需要手动处理。
3.4 显示Cart的Content
RedirectToAction方法,它的效果是,发送一个HTTP重定向指令,到客户端浏览器,让浏览器请求一个新的URL。在这个例子中,我们让浏览器请求Cart controller的Index action。
我们会实现Index方法,用它播放Cart的contents。偶们需要传递两个信息碎片给view:Cart对象和如果用户点击继续购物按钮后要显示的URL。为了这个目的,我们会创建一个简单的视图模型类,CartIndexViewModel。
1
public
class
CartIndexViewModel
2
{
3
public
Cart Cart {
get
;
set
; }
4
public
string
ReturnUrl {
get
;
set
; }
5
}
然后在CartController中添加Index方法
1
public
ViewResult Index(
string
returnUrl)
2
{
3
return
View(
new
CartIndexViewModel
4
{
5
Cart
=
GetCart(),
6
ReturnUrl
=
returnUrl
7
});
8
}
并使用CartIndexViewModel(SportsStore.WebUI.Models)创建强类型视图。我们想在显示cart的content时,一如既往地与程序的其他部分页面一样,所以没有填layout,它会默认地使用_Layout.cshtml文件。
1
@model SportsStore.WebUI.Models.CartIndexViewModel
2
3
@{
4
ViewBag.Title
=
"
Sport Store : Your Cart
"
;
5
}
6
7
<
h2
>
Your cart
</
h2
>
8
<
table width
=
"
90%
"
align
=
"
center
"
>
9
<
thead
>
10
<
tr
>
11
<
th align
=
"
center
"
>
Quantity
</
th
>
12
<
th align
=
"
left
"
>
Item
</
th
>
13
<
th align
=
"
right
"
>
Price
</
th
>
14
<
th align
=
"
right
"
>
Subtotal
</
th
>
15
</
tr
></
thead
>
16
<
tbody
>
17
@foreach(var line
in
Model.Cart.Lines){
18
<
tr
>
19
<
td align
=
"
center
"
>
@line.Quantity
</
td
>
20
<
td align
=
"
left
"
>
@line.Product.Name
</
td
>
21
<
td align
=
"
right
"
>
@line.Product.Price.ToString(
"
c
"
)
</
td
>
22
<
td align
=
"
right
"
>
@((line.Quantity
*
line.Product.Price).ToString(
"
c
"
))
</
td
>
23
</
tr
>
24
}
25
</
tbody
>
26
<
tfoot
>
27
<
tr
>
28
<
td colspan
=
"
3
"
align
=
"
right
"
>
Total:
</
td
>
29
<
td align
=
"
right
"
>
30
@Model.Cart.ComputeTotalValue().ToString(
"
c
"
)
31
</
td
>
32
</
tr
>
33
</
tfoot
>
34
</
table
>
35
<
p align
-
"
center
"
class
=
"
actionButtons
"
>
36
<
a href
=
"
@Model.ReturnUrl
"
>
Continue shopping
</
a
>
37
</
p
>
38
它枚举购物车中的行,将每行添加到HTML表,伴随着每行总额和购物车总额。当我们点击继续购物按钮,会回到来时的页面。
4 使用模型绑定
MVC框架使用一个叫做model binding的系统,从来自HTTP查询,创建C#对象,为了将他们作为参数值传递给action方法。这是MVC如何处理表单。框架查看被触发的action方法的参数,并使用一个model binder,得到表单input元素的值,并使用相同的名字,将他们转换为参数的类型。
Model binders可以从有效查询的任何信息创建C#类型。这是MVC框架的中心特性之一。我们要创建一个自定义的模型绑定,来改进CartController类。
我们喜欢使用session状态特性,来存储和管理Cart对象。但是我们确实不喜欢这种方式。它不适合我们其他部分的程序模型,那些基于action方法参数。我们不能在CartController类使用单元测试,除非我们Mock Session,这意味着mocking真个controller类。
为了解决这个问题,我们要创造一个自定义model binder,获得session data中包含的cart对象。MVC框架然后会创建Cart对象,传递他们作为参数给action方法。模型绑定特性是非常强大和灵活的。
4.1 创建自定义Model Binder
我们创建自定义model binder,以实现IModelBinder接口。在SportsStore.WebUI中新建Binders文件,在它里面创建CartModelBinder类。
1
public
class
CartModelBinder:IModelBinder
2
{
3
private
const
string
sessionKey
=
"
Cart
"
;
4
5
public
object
BindModel(ControllerContext controllerContext,ModelBindingContext bindingContext)
6
{
7
//
get the Cart from the session
8
Cart cart
=
(Cart)controllerContext.HttpContext.Session[sessionKey];
9
//
create the Cart if there wasn't one in the session data
10
if
(cart
==
null
){
11
cart
=
new
Cart();
12
controllerContext.HttpContext.Session[sessionKey]
=
cart;
13
}
14
//
return the cart
15
return
cart;
16
}
17
}
IModelBinder接口定义了一个方法:BindModel。两个参数用来创建领域模型对象。ControllerContext提供访问controller拥有的所有信息,包括客户端的查询详情。ModelBindingContext给你关于你将要构建的模型对象的信息。
出于这个目的,ControllerContext类是我们感兴趣的。它由HttpContext属性,它可以给我们sesson属性,并设置session data。偶们通过读取session data的key的value,获得Cart,如果它不存在,就创建它。
偶们需要告诉MVC框架,它可以使用CartModelBinder类,创建Cart的实例。在Global.asax的Application_Start中添加
1
ModelBinders.Binders.Add(
typeof
(Cart),
new
CartModelBinder());
现在我们可以将GetCart从CartController中移除,并使用我们的模型绑定。
1
public
ViewResult Index(Cart cart,
string
returnUrl)
2
{
3
return
View(
new
CartIndexViewModel
4
{
5
Cart
=
cart,
6
ReturnUrl
=
returnUrl
7
});
8
}
我们移除了GetCart方法,并为每个action方法添加了Cart参数。当MVC框架收到请求,AddToCart方法被调用,它开始查找action方法的参数。它查看可用绑定的列表,尝试着找到一个能创建参数类型的实例。我们自定义的绑定,被要求创建一个Cart对象,它使用session状态特性完成工作。在我们的绑定和默认绑定之间,MVC框架会创建一组调用action方法必须的参数。允许我们重构controller。
使用自定义绑定有一些益处。第一,偶们分离了用来创建Cart的逻辑,从Controller。它允许偶们改变我们存储Cart对象的方法,而不需要改变controller。第二,用到Cart对象的任何Controller类,都能简单地将他们声明为action的参数,并改进自定义模型绑定。第三,是最重要的一点,偶们可以对Cartcontroller进行单元测试了,而不需要mock许多ASP.NET管道。
4..2 使用单元测试cart controller
通过创建Cart对象,并将他们传递给action方法,我们可以测试CarController类。需要测试controller的三个不同的方面:
- AddToCart方法应该添加被选择的product到用户的cart
- 在添加product到cart后,需要重定向到Index View
- 用户返回到分类的url,必须准确地传递给Index action方法
1
[TestMethod]
2
public
void
Can_Add_To_Cart()
3
{
4
//
Arrange - create the mock repository
5
Mock
<
IProductRepository
>
mock
=
new
Mock
<
IProductRepository
>
();
6
mock.Setup(m
=>
m.Products).Returns(
7
new
Product[]
8
{
9
new
Product{ProductID
=
1
,Name
=
"
P1
"
,Category
=
"
Apples
"
}
10
}.AsQueryable());
11
12
//
Arrange - create a Cart
13
Cart cart
=
new
Cart();
14
15
//
Arragne - create the controller
16
CartController target
=
new
CartController(mock.Object);
17
18
//
Act - add a product to the cart
19
target.AddToCart(cart,
1
,
null
);
20
21
//
Assert
22
Assert.AreEqual(cart.Lines.Count(),
1
);
23
Assert.AreEqual(cart.Lines.ToArray()[
0
].Product.ProductID,
1
);
24
}
25
26
[TestMethod]
27
public
void
Adding_Product_To_Cart_Goes_To_Cart_Screen()
28
{
29
//
Arrange - create the mock repository
30
Mock
<
IProductRepository
>
mock
=
new
Mock
<
IProductRepository
>
();
31
mock.Setup(m
=>
m.Products).Returns(
32
new
Product[]
33
{
34
new
Product{ProductID
=
1
,Name
=
"
P1
"
,Category
=
"
Apples
"
}
35
}.AsQueryable());
36
37
//
Arrange - create a Cart
38
Cart cart
=
new
Cart();
39
40
//
Arragne - create the controller
41
CartController target
=
new
CartController(mock.Object);
42
43
//
Act - add a product to the cart
44
RedirectToRouteResult result
=
target.AddToCart(cart,
2
,
"
myUrl
"
);
45
46
//
Assert
47
Assert.AreEqual(result.RouteValues[
"
action
"
],
"
Index
"
);
48
Assert.AreEqual(result.RouteValues[
"
returnUrl
"
],
"
myUrl
"
);
49
}
50
51
[TestMethod]
52
public
void
Can_View_Cart_Contents()
53
{
54
//
Arrange - create a Cart
55
Cart cart
=
new
Cart();
56
57
//
Arragne - create the controller
58
CartController target
=
new
CartController(
null
);
59
60
//
Act - call the Index action method
61
CartIndexViewModel result
=
(CartIndexViewModel)target.Index(cart,
"
myUrl
"
).ViewData.Model;
62
63
//
Assert
64
Assert.AreEqual(result.Cart,cart);
65
Assert.AreEqual(result.ReturnUrl,
"
myUrl
"
);
66
}
67
}
5 完成购物车
添加两个心的特性,第一个是移除商品,第二个是在页面顶部显示商品总数
5.1 从购物车移除商品
我们已经定义并而是了RemoveFromCart action方法,需要把它放到视图,在购物车汇总的每一行添加Remove按钮。
1
<
td
align
="right"
>
@((line.Quantity*line.Product.Price).ToString("c"))
</
td
>
2
<
td
>
3
@using(Html.BeginForm("RemoveFromCart","Cart")){
4
@Html.Hidden("ProductId",line.Product.ProductID)
5
@Html.HiddenFor(x=>x.ReturnUrl)
6
<
input
class
="actionButtons"
type
="submit"
value
="Remove"
/>
7
}
8
</
td
>
我们可以使用强类型Html.HiddenFor helper方法,为模型属性ReturnUrl创建一个隐藏域,但是我们需要使用基于字符串的Html.Hidden helper为ProductID域。如果我们写成
1
@Html.HiddenFor(x => line.Product.ProductID)
helper会渲染一个
1
name="line.Product.ProductID" type="hidden" value="2"
的 field。field的name不能匹配CartController.RemoveFromCart action放的的参数名,它会防止默认的模型绑定工作,所以MVC框架不能调用这个方法。
1
public RedirectToRouteResult RemoveFromCart(Cart cart,int productId,string returnUrl)
2
3
<
input
id
="ProductID"
name
="ProductID"
type
="hidden"
value
="1"
/>
4
<
input
id
="ReturnUrl"
name
="ReturnUrl"
type
="hidden"
value
="/Watersports"
/>
name与参数名相匹配。
5.2 添加购物车汇总
我们需要把购物车放在界面上。客户可以屏幕上看到购物车中,商品的数量。他们可以看到一个一个新商品进入购物车。
要做到这点,我们需要添加一个控件,汇总购车的contents,被点击后显示购物车的contents。这和导航控件很相似,做一个注入到Razor layout的action。
在CartController中添加
1
public
ViewResult Summary(Cart cart)
2
{
3
return
View(cart);
4
}
它仅需要渲染一个视图,提供当前Cart(从我们自定义的模型绑定中获得)作为视图数据。我们需要创建一个局部视图,它会在Summary方法被调用时,在response中被渲染。创建Summary的局部视图,强类型Cart。
1
@model SportsStore.Domain.Entities.Cart
2
3
@{
4
Layout = null;
5
}
6
7
<
div
id
="cart"
>
8
<
span
class
="caption"
>
9
<
b
>
Your cart:
</
b
>
10
@Model.Lines.Sum(x=>x.Quantity) item(s),
11
@Model.ComputeTotalValue().ToString("c")
12
</
span
>
13
14
@Html.ActionLink("Checkout", "Index", "Cart", new { returnUrl = Request.Url.PathAndQuery },null)
15
</
div
>
在_Layout.cshtml文件中?:
1
<
div
id
="header"
>
2
@{Html.RenderAction("Summary", "Cart");}
3
<
div
class
="title"
>
SPORTS STORE
</
div
>
4
</
div
>
使用RenderAction,结合action方法,渲染输出到页面。这是个不错的技术,打碎了程序的功能,使之成为不同的,可以再度重用的块。
6 提交订单
现在,偶们到达了最后一个客户特性,结账的能力和完成订单。接下来,我们会扩展领域模型,支持从用户捕捉购物明细,并添加处理这些细节的特性。
6.1 扩展领域模型
在Entities中添加ShippingDetails类,这个类代表了用户的购物明细。
1
public
class
ShippingDetails
2
{
3
[Required(ErrorMessage
=
"
Please enter a name
"
)]
4
public
string
Name {
get
;
set
; }
5
6
[Required(ErrorMessage
=
"
Please enter the first address line
"
)]
7
public
string
Line1 {
get
;
set
; }
8
public
string
Line2 {
get
;
set
; }
9
public
string
Line3 {
get
;
set
; }
10
11
[Required(ErrorMessage
=
"
Please enter a city name
"
)]
12
public
string
State {
get
;
set
; }
13
14
public
string
Zip {
get
;
set
; }
15
16
[Required(ErrorMessage
=
"
Please enter a country name
"
)]
17
public
string
Country {
get
;
set
; }
18
19
public
bool
GiftWrap {
get
;
set
; }
20
}
使用了System.ComponentModel.DataAnnotations的验证属性。必须添加引用才能使用。ShippingDetails类中没有任何函数,所以我们没有明显的单元测试。
6.2 添加结账处理
我们的目标是用户可以输入他们的购物详情,并提交订单。我们需要添加Checkout now按钮到Views/Cart/Index.cshtml文件。
1
<
p align
-
"
center
"
class
=
"
actionButtons
"
>
2
<
a href
=
"
@Model.ReturnUrl
"
>
Continue shopping
</
a
>
3
@Html.ActionLink(
"
Checkout now
"
,
"
Checkout
"
)
4
</
p
>
这个按钮调用了Cart/Checkout,所以要在CartController类中添加Checkout方法。这个方法返回默认视图,并传递一个新的ShippingDetails对象,作为视图模型。创建强类型视图,视图模型为ShippingDetails。
1
@model SportsStore.Domain.Entities.ShippingDetails
2
3
@{
4
ViewBag.Title
=
"
SportsStroe: Checkout
"
;
5
}
6
7
<
h2
>
Check
out
now
</
h2
>
8
Please enter your details, and we
'
ll ship your goods right away!
9
@using(Html.BeginForm()){
10
<
h3
>
Ship to
</
h3
>
11
<
div
>
Name: @Html.EditorFor(x
=>
x.Name)
</
div
>
12
13
<
h3
>
Address
</
h3
>
14
<
div
>
Line
1
: @Html.EditorFor(x
=>
x.Line1)
</
div
>
15
<
div
>
Line
2
: @Html.EditorFor(x
=>
x.Line2)
</
div
>
16
<
div
>
Line
3
: @Html.EditorFor(x
=>
x.Line3)
</
div
>
17
<
div
>
City: @Html.EditorFor(x
=>
x.City)
</
div
>
18
<
div
>
State: @Html.EditorFor(x
=>
x.State)
</
div
>
19
<
div
>
Zip: @Html.EditorFor(x
=>
x.Zip)
</
div
>
20
<
div
>
Country: @Html.EditorFor(x
=>
x.Country)
</
div
>
21
22
<
h3
>
Options
</
h3
>
23
<
label
>
24
@Html.EditorFor(x
=>
x.GiftWrap)
25
</
label
>
26
27
<
p align
=
"
center
"
>
28
<
input
class
=
"
actionButtons
"
type
=
"
submit
"
value
=
"
Complete order
"
/>
29
</
p
>
30
}
31
32
使用Html.EditorFor helper方法,为每个表单域渲染了input元素。这个方式是一个templated view helper。我们让MVC框架画出input元素类型的必须的视图模型属性,而不是明确地使用Html.TextBoxFor指定它。
我们看到模板视图助手,多么只能地为我们的bool属性,渲染了一个checkbox。为string属性渲染了textbox。
我们将来会使用Html.EditorForModel helper方法,它会为ShippingDetails视图模型类的所有属性生成一个label和一个inputs。然而,我们想将name,address区分开来,并且显示在表单的不同区域,所以简单地直接参照每个属性。
6.3 实现Order Processor
我们需要一个组件,提交订单给处理。为了保持MVC模型的原则,我们为这个功能定义一个接口,并写一个它的实现,关联到DI容器和Ninject。
6.3.1 定义接口
在Abstrack文件夹中创建新接口IOrderProcessor。
1
public
interface
IPrderProcessor
2
{
3
void
ProcessOrder(Cart cart, ShippingDetails shippingDetails);
4
}
6.3.2 接口的实现
IOrderProcessor的实现,用来处理订单,发e-mail给管理员。当然,我们简化了销售过程。大多数电子贸易网站,不会简单地将order发e-mail,但是我们不提供处理信用卡或其他形式的支付的支持。我们只想关注MVC,所以经它发e-mail。
在Concrete文件夹中创建EmailOrderProcessor类。这个类使用了.NET框架内建的SMTP支持,来发送e-mail。
为了让事情变得简单,我们定义了EmailSettings类,EmailOrderProcessor的构造器方法需要这个类的实例,它包含.NET e-mail类需要的所有配置。
不要担心没有SMTP可用,如果设置了EmailSetting.WriteAsFile属性为true,e-mail messages会被直接写入FileLocation指定的文件。这个途径必须存在而且可以写入。文件会以.eml扩展。
6.4 注册实现
现在偶们有了IOrderProcessor接口的实现,意味着可以配置它。我们可以使用Ninject创建它的实例。在NinjectControllerFactory类中添加绑定。
1
private
void
AddBindings()
2
{
3
ninjectKernel.Bind
<
IProductRepository
>
().To
<
EFProductRepository
>
();
4
5
EmailSettings emailSettings
=
new
EmailSettings
6
{
7
WriteAsFile
=
bool
.Parse(ConfigurationManager.AppSettings[
"
Email.WriteAsFile
"
]
??
"
false
"
)
8
};
9
10
ninjectKernel.Bind
<
IOrderProcessor
>
().
11
To
<
EmailOrderProcessor
>
().WithConstructorArgument(
"
settings
"
, emailSettings);
12
}
我们创建了一个EmailSettings对象,当IOrderProcessor接口被请求创建一个新的实例时,偶们使用Ninject WithConstructorArgument方法将它注入到EmailOrderProcessor的构造器中。我只指定了一个属性,WriteAsFile。它允许我们访问Web.config文件中的程序设置。
1
<
appSettings
>
2
<
add key
=
"
ClientValidationEnabled
"
value
=
"
true
"
/>
3
<
add key
=
"
UnobtrusiveJavaScriptEnabled
"
value
=
"
true
"
/>
4
<
add key
=
"
Email.WriteAsFile
"
value
=
"
true
"
/>
5
</
appSettings
>
6.5 完成Cart Controller
要完成CartController类,我们需要修改构造函数,让它需要一个IOrderProcessor接口的实例,并添加一个新的action方法,处理当用户点击Complete Order按钮时的HTTP表单POST。
1
private
IProductRepository repository;
2
private
IOrderProcessor orderProcessor;
3
4
public
CartController(IProductRepository repo,IOrderProcessor proc)
5
{
6
repository
=
repo;
7
orderProcessor
=
proc;
8
}
9
10
[HttpPost]
11
public
ViewResult Checkout(Cart cart, ShippingDetails shippingDetails)
12
{
13
if
(cart.Lines.Count()
==
0
){
14
ModelState.AddModelError(
""
,
"
Sorry,your cart is empty!
"
);
15
}
16
17
if
(ModelState.IsValid){
18
orderProcessor.ProcessOrder(cart, shippingDetails);
19
cart.Clear();
20
return
View(
"
Completed
"
);
21
}
else
22
{
23
return
View(shippingDetails);
24
}
25
}
Checkout方法使用HttpPost属性装饰,这意味着它会通过POST查询的方式调用。当用户提交表单。再一次,你依赖模型绑定系统,包括ShippingDetails参数(它通过HTTP表单数组自动被创建)和Cart参数(它使用自定义绑定创建)。
这个改变需要我们变更CartController类的单元测试,传递Null为新的构造器参数。
MVC框架会检查ShippingDetails的date annotation属性的验证约束。任何违反的都会通过ModelState属性传递给action方法。我们可以通过检查ModelState.IsValid属性,看看这里有没有问题。注意,如果购物车为空,我们调用Modelstate.AddModelError方法,注册一个错误消息。
6.5.1 订单处理的单元测试
要使得CartController类的单元测试变得完整,需要测试Checkout的重写方法。
1
[TestMethod]
2
public
void
Cannot_Checkout_Empty_Cart()
3
{
4
//
Arrange - create a mock order processor
5
Mock
<
IOrderProcessor
>
mock
=
new
Mock
<
IOrderProcessor
>
();
6
//
Arrange - create an empty cart
7
Cart cart
=
new
Cart();
8
//
Arrange - create shipping details
9
ShippingDetails shippingDetails
=
new
ShippingDetails();
10
//
Arrange - create an instance of the controller
11
CartController target
=
new
CartController(
null
, mock.Object);
12
13
//
Act
14
ViewResult result
=
target.Checkout(cart, shippingDetails);
15
16
//
Assert - check that the order hasn't been passed on to the processor
17
mock.Verify(m
=>
m.ProcessOrder(It.IsAny
<
Cart
>
(), It.IsAny
<
ShippingDetails
>
()), Times.Never());
18
//
Assert - check that the method is returning the default view
19
Assert.AreEqual(
""
, result.ViewName);
20
//
Assert - check that we are passing an invalid model to the view
21
Assert.AreEqual(
false
, result.ViewData.ModelState.IsValid);
22
}
23
24
[TestMethod]
25
public
void
Cannot_Checkout_Invalid_ShippingDetails()
26
{
27
//
Arrange - create a mock order processor
28
Mock
<
IOrderProcessor
>
mock
=
new
Mock
<
IOrderProcessor
>
();
29
30
//
Arrange - create a cart with an item
31
Cart cart
=
new
Cart();
32
cart.AddItem(
new
Product(),
1
);
33
34
//
Arrange - create an instance of the controller
35
CartController target
=
new
CartController(
null
, mock.Object);
36
//
Arrange - add an error to the model
37
target.ModelState.AddModelError(
"
error
"
,
"
error
"
);
38
39
//
Act - try to checkout
40
ViewResult result
=
target.Checkout(cart,
new
ShippingDetails());
41
42
//
Assert - check that the order hasn't been passed on the processor
43
mock.Verify(m
=>
m.ProcessOrder(It.IsAny
<
Cart
>
(), It.IsAny
<
ShippingDetails
>
()), Times.Never());
44
//
Assert - check that the method is returning the default view
45
Assert.AreEqual(
""
, result.ViewName);
46
//
Assert - check that we are passing an invalid model to the view
47
Assert.AreEqual(
false
, result.ViewData.ModelState.IsValid);
48
49
}
50
51
[TestMethod]
52
public
void
Can_Checkout_And_Submit_Order()
53
{
54
//
Arrange - create a mock order processor
55
Mock
<
IOrderProcessor
>
mock
=
new
Mock
<
IOrderProcessor
>
();
56
//
Arrange - create a cart with an item
57
Cart cart
=
new
Cart();
58
cart.AddItem(
new
Product(),
1
);
59
//
Arrange - create an instance of the controller
60
CartController target
=
new
CartController(
null
, mock.Object);
61
62
//
Act - try to checkout
63
ViewResult result
=
target.Checkout(cart,
new
ShippingDetails());
64
65
//
Assert - check that the order has been passed on to the processor
66
mock.Verify(m
=>
m.ProcessOrder(It.IsAny
<
Cart
>
(), It.IsAny
<
ShippingDetails
>
()), Times.Never());
67
//
Assert - check that the method is returning the Completed vie
68
Assert.AreEqual(
"
Completed
"
, result.ViewName);
69
//
Assert - check that we are passing a valid model to the view
70
Assert.AreEqual(
true
, result.ViewData.ModelState.IsValid);
71
}
测试确保了不能check out使用空购物车。我们检查这点,通过确保mock IOrderProcessor实现的ProcessOrder永远不会被调用。model state被标记为invalid,传递给view。
6.6 显示验证错误
如果用户输入不能通过验证的信息,这个表单域就会高亮,但不显示错误信息。如果用户使用空的购物车结账,我们不让他完成订单,但是它看不到任何错误信息。为了解决这点,我们需要添加验证汇总到Checkout.cshtml视图。
1
Please enter your details, and we
'
ll ship your goods right away!
2
@using (Html.BeginForm()) {
3
4
@Html.ValidationSummary()
5
6
<
h3
>
Ship to
</
h3
>
6.7 显示总结页面
我们要显示一个确认订单已经处理完毕,感谢他们购买。为Checkout方法添加Completed视图。
1
@{
2
ViewBag.Title
=
"
SportsStore: Order Submitted
"
;
3
}
4
5
<
h2
>
Thanks
!</
h2
>
6
Thanks
for
placing your order. We
'
ll ship your goods as soon as possible.
7
7 总结
偶们有一个可以浏览分类和页面的产品分类。一个优雅的购物车,一个简单的结账过程。完好分离的建筑学,以为着偶们可以简单的改变程序任意一部分的功能,而不用担心产生问题或与其他地方不一致。例如,我们可以使用数据库存储订单,并且对它在购物车中,产品分类中,或程序的任何部分,都没有影响。