ASP-NET-MVC4-移动应用开发-全-
ASP.NET MVC4 移动应用开发(全)
零、前言
今天的网络开发者在向市场交付他们的产品时面临许多挑战。曾几何时,应用只需要在 Internet Explorer 中正常运行即可。今天的应用不仅必须在多个浏览器中运行,还必须在运行于多个设备上的多个操作系统上运行。
ASP.NET MVC 4 移动应用开发旨在作为成功构建针对当前桌面浏览器和旨在消费移动网络的浏览器的网络应用的指南。
ASP.NET MVC 4 移动应用开发带您走过创建 web 应用从概念到交付的过程。
这本书涵盖了什么
在本书的第一部分,我们构建了一个功能齐全的示例应用来管理和共享自制啤酒的配方。第 1 章为移动网络开发,首先讨论了理解如何为移动网络开发的重要性。然后给你一个我们如何到达今天的移动网络的简短历史。本章最后讨论了作为开发人员,如果我们要成功地为移动网络开发应用,我们必须承认的所有限制。
第二章、酿酒与你,向你介绍酿酒与酿酒领域——我们的酿酒配方分享应用。根据我们对这个领域的理解,我们构建了应用的需求,然后研究了 ASP.NET MVC 4 及其新特性将如何帮助我们开发它。我们通过设置我们的环境来启动我们的移动仿真器来设计和测试我们的应用来结束这一章。
第三章、介绍 ASP.NET MVC 4,介绍 MVC 模式。然后,当我们开始构建应用时,我们讨论如何在 ASP.NET MVC 4 中实现它。到本章结束时,我们应用的初始外壳将完成,并在我们的桌面和移动浏览器中运行。
第 4 章、在 EF5 中建模 BrewHow,带领我们创建 BrewHow 的数据模型。我们首先讨论实体框架的新特性和改进,并使用 EF 中的代码优先模型为我们的领域创建一个数据模型和数据库。通过使用迁移,模型和数据库被不断地优化,直到我们的应用的需求得到满足,并且我们的应用正在使用来自 LocalDB 的数据。
第 5 章酿酒领域和领域驱动的设计,向我们介绍了 DDD 的原则。我们通过创建存储库、视图模型和域实体来加强持久性、逻辑和显示之间的界限,从而将这些原则应用到 BrewHow 中。
第 6 章、编写可维护代码,首先讨论面向对象设计的 SOLID 原则;有助于编写可维护代码的原则。然后,我们回顾依赖注入和控制反转的主题,作为帮助我们巩固 BrewHow 代码库的工具。本章最后将这些原则和工具应用到代码库中,利用 ASP.NET MVC 4 提供给我们的扩展点。
第 7 章、使用路由和区域分离功能,详细讨论了 ASP.NET MVC 4 中的这一关键技术。我们研究如何通过利用路由和区域,我们可以创建有意义的搜索引擎优化友好的网址,并将酿酒厂的功能分成酿酒厂包含的内容的独立工作单元。然后,我们通过增加对用户评论的支持来锻炼我们的知识。
第 8 章、验证用户输入,查看中包含的服务器端数据验证支持.NET Framework 的System.ComponentModel.DataAnnotations命名空间。数据模型和视图模型被修改以支持这些属性的使用,因此我们可以在客户端验证输入,并在数据到达服务器后再次验证。我们还探讨了这些技术如何帮助保护啤酒如何免受常见的网络攻击,如 CSRF 和 XSS。
第 9 章、识别和授权用户,介绍了 ASP.NET MVC 4 中提供的新会员功能。BrewHow 被修改为使用新的会员功能支持用户身份验证,然后我们修改应用以允许使用谷歌登录进行身份验证。我们通过将 BrewHow 数据模型与会员数据模型结合在一起来结束这一章,这样我们就可以将食谱和评论与创建它们的用户联系起来。
我们的应用现在在功能上已经完成,并且已经满足了第 2 章、酿酒和您中提出的所有要求。在本书的第二部分,我们来看看 ASP.NET MVC 4 提供的一些高级特性。第 10 章、异步编程和捆绑探讨了我们如何设计应用的服务器端部分,以更高效、更短的等待时间将信息传递给用户,这对于移动应用至关重要。为了实现这一点,我们首先检查并实现对异步动作的支持。然后,我们检查以包的形式提供给我们的缩小支持。
第 11 章、实时网络编码研究了我们如何使用始终在线的连接在 BrewHow 中提供桌面应用的错觉。然后,我们利用 SignalR 来模拟从服务器到 BrewHow 的推送通知。
随着我们的应用完全优化以向移动设备提供内容,我们现在可以开始本书的第三部分。第 12 章、为移动设备设计你的应用讨论了我们如何使用下一代网络标准 HTML5 和 CSS3 来创建响应性标记,以最适合用户正在查看的设备的方式向用户呈现我们的内容。
第 13 章、扩展对移动 Web 的支持在我们探索 ASP.NET MVC 4 内置的移动视图支持时,将移动设计的概念扩展到了我们的服务器。然后,我们将这一概念扩展到使用新的显示模式支持的特定移动设备。
第 14 章、利用 jQuery Mobile 改善用户体验,展示了如何将 BrewHow 转换为移动网络应用,在外观和感觉上就像是该设备的原生产品。我们查看了 jQuery Mobile 提供的一些控件,并将它们应用到我们所学的所有内容中,以构建一个完善且功能齐全的移动应用。
第 15 章、读者挑战,展示了如何将 BrewHow 扩展为用户更丰富的体验。我们讨论如何将全文搜索技术集成到 BrewHow 中,如何为社交网络提供支持,以及如何将 BrewHow 扩展到真正的本地移动应用中。然后鼓励读者自己承担这些任务。
这本书你需要什么
要在本书中构建示例应用,您需要一份 Microsoft Visual Studio Express for Web 2012。要查看示例应用,您需要一个能够支持 HTML5 和 CSS3 的网络浏览器。本书中的示例应用是使用运行在 Windows 8 上的谷歌 Chrome 和 Opera Mobile Emulator 的当前版本进行测试的。
这本书是给谁的
这本书是为任何希望学习 ASP.NET MVC 4 及其在开发面向移动网络的应用中的作用的个人准备的。这本书的材料假定读者已经熟悉了.NET 框架和对 C#的接触。如果你是 ASP.NET MVC 的新手,想要一个好的扎实的入门,如果你想了解 ASP.NET MVC 4 的新功能,或者如果你想了解如何修改你的 web 应用来支持多种设备,这本书是为你准备的。
惯例
在这本书里,你会发现许多区分不同种类信息的文本风格。以下是这些风格的一些例子,以及对它们的含义的解释。
文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、伪 URL、用户输入和推特句柄如下所示:“在 MVC 的早期版本中,所有应用引导代码都位于Global.asax代码隐藏中。
代码块设置如下:
.white-go
{
width:31px;
background:url('img-sprite.png') 0 0;
}
.orange-go
{
width: 31px;
background:url('img-sprite.png') -32px 0;
}
当我们希望将您的注意力吸引到代码块的特定部分时,相关的行或项目以粗体显示:
public interface IBrewHowContext
{
IDbSet<Models.Recipe> Recipes { get; set; }
IDbSet<Models.Review> Reviews { get; set; }
IDbSet<Models.Style> Styles { get; set; }
IDbSet<Models.UserProfile> UserProfiles { get; set; }
int SaveChanges();
}
public class BrewHowContext : DbContext, IBrewHowContext
{
public IDbSet<Models.Recipe> Recipes { get; set; }
public IDbSet<Models.Review> Reviews { get; set; }
public IDbSet<Models.Style> Styles { get; set; }
public IDbSet<Models.UserProfile> UserProfiles { get; set; }
public BrewHowContext()
: base("DefaultConnection")
{
}
/* ... */
}
新名词和重要词语以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,出现在如下文本中:“选择ASP.NET MVC 4 网络应用图标,并在名称和位置文本框中分别提供新项目的名称和位置”。
注
警告或重要提示会出现在这样的框中。
类型
提示和技巧是这样出现的。
读者反馈
我们随时欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
要给我们发送一般反馈,只需向<[feedback@packtpub.com](mailto:feedback@packtpub.com)>发送电子邮件,并通过您的消息主题提及书名。
如果你对某个主题有专业知识,并且对写作或投稿感兴趣,请参阅我们在www.packtpub.com/authors上的作者指南。
客户支持
现在,您已经自豪地拥有了一本书,我们有许多东西可以帮助您从购买中获得最大收益。
下载示例代码
您可以从您在http://www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问http://www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
勘误表
尽管我们尽了最大努力来确保我们内容的准确性,但错误还是会发生。如果你在我们的某本书里发现了错误——可能是文本或代码中的错误——如果你能向我们报告,我们将不胜感激。通过这样做,你可以让其他读者免受挫折,并帮助我们改进这本书的后续版本。如果您发现任何勘误表,请访问http://www.packtpub.com/submit-errata,选择您的书籍,点击勘误表提交表链接,并输入您的勘误表的详细信息。一旦您的勘误表得到验证,您的提交将被接受,勘误表将上传到我们的网站上,或添加到该标题的勘误表部分下的任何现有勘误表列表中。通过从http://www.packtpub.com/support中选择您的标题,可以查看任何现有的勘误表。
盗版
互联网上版权材料的盗版是所有媒体的一个持续问题。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上遇到任何形式的我们作品的非法拷贝,请立即向我们提供位置地址或网站名称,以便我们寻求补救。
请通过<[copyright@packtpub.com](mailto:copyright@packtpub.com)>联系我们,获取疑似盗版资料的链接。
我们感谢您在保护我们作者方面的帮助,以及我们为您带来有价值内容的能力。
问题
如果您对本书的任何方面有问题,可以在<[questions@packtpub.com](mailto:questions@packtpub.com)>联系我们,我们将尽最大努力解决。
一、面向移动网络的开发
如果你对未来开发网络应用感兴趣,了解移动设备扮演的越来越重要的角色,以及如何开发适合其功能的应用是很重要的。我这么说不是为了吓你买我的书(尽管我希望你现在正在阅读你购买的书),而是为了强调一个事实,即移动计算将在每个有联系的人的生活中发挥越来越大的作用。
要欣赏手机使用量的增长,应该考虑一下 iPhone。通常被誉为开启当前移动计算革命的智能手机的 iPhone,直到 2007 年才推出。2008 年底,也就是发布一年多后,移动流量占全球互联网流量的比例还不到 1%,算不上什么大革命。
然而,到 2010 年底,移动流量占所有互联网流量的近 5%,到 2012 年底,这一比例接近 13%。2013 年过半,移动流量已经超过了全部互联网流量的 15%。这一趋势大致是 1.5 的倍数,同比增长,并且有可能加速。
2012 年第四季度,iPad 总出货量达到约 140,000,000 台,约为 iPhones 总出货量的 3 倍。iPad 是在 iPhone 推出 3 年后推出的,而就在 iPad 发起平板电脑革命 3 年后,2012 年第四季度平板电脑的总出货量超过了台式机和笔记本电脑的出货量。
作为开发人员,重要的是我们要理解并拥抱这场移动革命,否则我们将被它挤垮。
在本书中,我们将使用 ASP.NET MVC 4、HTML5 和 CSS3 构建一个功能齐全的网络应用,以支持桌面和移动计算平台。我们将从构建桌面版本的 web 应用开始,但会将移动考虑因素考虑在内。
一旦我们的桌面应用完成,我们将使用响应设计和媒体查询等概念对其进行修改,以支持移动网络。在这次修改中,我们将研究 ASP.NET MVC 4 的新特性,我们可以用它来更好地定位移动网络设备。
在本书的最后几章中,我们将修改应用,以支持使用 jQuery Mobile 的真正移动体验。我的目标是,到本书最后一章结束时,您将完全了解为移动网络开发需要什么,以及将您的移动网络应用提升到下一个水平的工具。
在这一章中,我们将从研究移动网络的历史开始。这种理解对于理解过去几年前所未有的增长至关重要。本章还将强调过去、现在和将来针对移动设备时存在的一些限制。最后,我们将预览微软 ASP.NET MVC 4 中的新平台支持。
我们进入移动网络的旅程现在开始了。
移动网络的历史
如果不知道移动网络是如何开始的,就不可能理解我们可以为移动设备开发的便利性。如果移动网络真的有用,那它本身就是一个壮举,它需要多种技术的融合才能实现。
诺基亚 9000
可以说,诺基亚 9000 是第一款移动网络设备。这款手机由诺基亚在 1996 年开发,重达 14 盎司(397 克),由英特尔 i386 驱动。它配备了 640 x 200 像素灰度液晶显示器。这款手机允许用户发送传真、收发电子邮件和上网。它还配备了用于访问大型机系统的终端和远程登录应用。
市场碎片化
在此期间,诺基亚与爱立信等竞争移动数据空间的控制权。诺基亚 9000 被设计成使用窄带插座,这是一种由诺基亚开发和倡导的通信协议。要在诺基亚 9000 上显示的信息使用标记文本标记语言 ( TTML ) 返回到手机,这是一种标记语言,内容提供商可以通过从显示和传输中移除无关信息来优化移动设备的网页。
大约在同一时间,爱立信开发了智能终端传输协议(【ITTP】)。ITTP 是爱立信对移动网络的专有标记。
对主要手机制造商来说,很明显,除非他们能够开发一个通用标准,在他们的设备上启用移动网络,否则市场分裂将不可避免。
WAP 1.0 和 WML
1997 年 6 月 26 日,诺基亚、爱立信、摩托罗拉和 Unwired Planet 公开宣布他们将在一个无线应用协议 ( WAP )上合作。WAP 1.0 是一个开放的协议,任何供应商都可以实现,这个新协议将使移动设备制造商能够从通信过程中数据丢失率本来就很高的移动设备连接到基于 IP 的互联网世界。
无线标记语言 ( WML ) 成为了设计在 WAP 1.0 上运行的应用的标准,是 HTML 和 XML 的二代衍生。
然而,WAP 和 WML 有一些缺点。协议和伴随的标记语言是为非常慢的数据网络和非常有限的显示能力而设计的。如果你的设备只有有限的数据输入能力和低分辨率显示器,那么 WML 可以很好地为你服务,但是随着智能手机和移动网络浏览器的出现,它们的桌面衍生产品,WAP 1.0 和 WML 变得不那么相关了。
WAP 2.0 和 XHTML MP
随着手机和掌上电脑的融合势头越来越大,需要新的标准来支持越来越多的网络移动设备。为了支持开始随移动设备提供的新浏览器,需要一种新的标记语言。
2001 年,WAP 论坛(现为开放移动联盟的一部分)将可扩展超文本标记语言移动配置文件 ( XHTML MP )改编自 XHTML Basic,取代 WML 成为 WAP 的默认协议。
注
当 WAP 成为美国、英国和欧洲的标准时,日本的标准 i-mode 是由 NTT DoCoMo 开发的。
新标准是短暂的。如今,大多数移动设备都附带支持最新 HTML 标准(包括 HTML5 和 CSS3)的浏览器,但向尽可能广泛的市场提供内容仍然是一种良好的做法。
持续发展制约
在我们的手机、平板电脑和其他移动设备上安装现代浏览器并不意味着我们不应该为移动网络用户提供便利。在移动设备上仍然有许多实时限制,作为开发者,我们在编写移动网络应用时应该考虑这些限制。我们不能简单地缩小网络应用的桌面版本,为用户提供满意的体验。在开发移动应用时,我们必须记住,运行我们的应用的移动设备与桌面应用相比,具有不同的处理、网络和呈现限制。
加工约束
今天的移动设备的处理能力是将人类送上月球的阿波罗导航计算机的几倍。然而,它们并没有无限的处理能力,而且处理能力比普通电脑要低得多。
为了适应处理能力的不足,移动网络应用应该避免在应用中运行高度密集的 JavaScript 函数、图像处理或任何其他处理器密集型操作,除非这对应用的功能是绝对必要的。
减轻客户端负载的一种方法是在将内容返回到移动设备之前在服务器上做出某些确定。这种做法被称为服务器端浏览器嗅探,允许应用返回针对特定设备的网页和内容,并限制了在客户端进行浏览器功能检查的需要。在此期间,您还可以对返回给客户端进行处理的数据进行预处理。这与当前的 web 开发趋势不同,在当前的 web 开发趋势中,数据通常被提交给客户端进行处理。
通过减少服务器返回给客户端的内容量,您还可以缓解移动设备固有的一些网络限制。
网络约束
虽然今天的移动网络与家庭宽带网络的速度相当,在某些情况下甚至超过了家庭宽带网络,但您的用户可能会受到数据限制、速度管理、公司政策或其他限制的约束,无法在移动设备上检索数据。
移动网络也固有地在传输中比陆基通信丢失更多的网络数据。这种数据丢失对应用和用户体验有两个影响。首先,数据包丢失要求 TCP/IP 堆栈实现请求重新发送丢失的数据包,并增加了必须通过网络发送的数据量。其次,你的应用需要被编写成能够经受住失败的请求,因为它肯定会发生。
作为开发者,我们如何确保我们的移动网络应用在这样的网络上提供出色的用户体验?
内容压缩
我们可以从开始,通过在服务器端压缩代表我们发送给客户端的内容的数据量。
服务器到客户端的压缩
内容压缩可以作为客户端应用和支持它的网络服务器之间通信的一部分。内容压缩的工作原理是提供静态内容,偶尔也提供动态内容,并使用 gzip 或 deflate 进行压缩,然后将其返回到请求应用。
为了让客户端表明它可以接受和处理内容,它必须发送一个Accept-Encoding HTTP 头,该头带有它将接受的编码类型的请求。
Accept-Encoding: gzip, deflate
在服务器上启用压缩是特定于供应商和版本的。应该注意的是,虽然在服务器上启用通信压缩确实减少了服务器必须发送的数据量,但它提高了服务器处理器的利用率。
除了压缩,我们还可以通过名为 缩小的流程,减少需要发送给客户端的数据量。
缩小
缩小是从我们的 HTML、CSS 和 JavaScript 文件中移除无关空白的行为。缩小不是典型意义上的压缩。缩小的好处是,虽然您减少了发送到客户端的数据量,但它可以立即使用,因为该数据中的任何功能都没有被删除。
一些缩小技术也可以作为一种混淆 JavaScript 的方式,让那些不怀好意的人更难破译你的代码在做什么。这是通过解析正在缩小的内容并将长变量重命名为 1 到 3 个字符来实现的。
类型
思考安全
切勿在客户端上执行任何要求您公开密钥、用户名、密码或其他敏感信息的操作。向客户端传输此信息会招致恶作剧。
图像优化
图像在你的应用将要提供给客户的内容中占了很大的比例。除了缩小之外,图像优化可能是缩小应用大小的最快方法之一。
较低的色深
或许在你的网站上优化图片最简单的方法就是降低图片的色深。网络上的大多数图像都是图标,可以很容易地用 8 位或 16 位色深的图像来表示。话虽如此,与其说它是一门科学,不如说它是一门艺术。随着当今移动设备显示器像素深度的增加,质量差的图像可能会降低您网站的功能,并可能会阻止一些用户使用它。
CSS 图像喷溅
图像精灵是可能在一个站点上使用的单个图像(包含多个图像)。然后,样式表使用不同的图像偏移量来引用图像子画面,以仅显示该图像的一部分。来自帕克特出版网站(www.packtpub.com)的以下图像是图像精灵的示例:

这张图片实际上是一张包含两张图片的图片,供网站使用。两幅图像都是 31 x 31 像素。从该图像中,我们可以创建以下两种样式:
.white-go
{
width:31px;
background:url('img-sprite.png') 0 0;
}
.orange-go
{
width: 31px;
background:url('img-sprite.png') -32px 0;
}
首先要注意的是,这两种风格的宽度都是以我们想要显示的实际图像的宽度为限,也就是 31 像素。
white-go类设置应用于子画面的元素的背景图像,并将图像的偏移量设置为左上角,即 0,0。由于图像被限制为 31 像素宽,图像的观看者将只看到图像中包含白色 go 按钮的部分。
orange-go类对图像显示有负偏移,告诉浏览器从像素 32 开始显示图像的 31 个像素。这仅显示橙色图像。
应用可以通过将定义的样式应用于 HTML 标记中的元素来重用这两个图像,但真正的好处是应用只向服务器发出一个请求来检索这两个图像。
数据 URIs
数据 URIs ( 通用资源标识符)允许您将内容数据直接放入 URI 链接。URI 是使用data:[<mediatype>][;base64],<data>格式格式化的。根据 RFC 2397,数据 URI 方案定义如下:
<媒体类型>是互联网媒体类型规范(可选参数)。的样子”;base64”意味着数据被编码为 base64。无”;base64”,对于安全 URL 字符范围内的八位字节使用 ASCII 编码,对于该范围外的八位字节使用 URL 的标准%xx 十六进制编码来表示数据(作为八位字节序列)。如果省略<中介类型>,则默认为文本/纯文本;字符集=美国-ASCII。
假设我们希望使用数据 URI 在页面中嵌入以下简单图像:

如果你将上面的图片作为一个 64 位编码的 URI 巴布亚新几内亚数据嵌入到你网站的一个页面中,你将在你的 HTML 源中构建一个数据 URI。

这为浏览器提供了不必单独请求检索图像的好处。通过一些巧妙的 JavaScript 和 CSS,您可以重用 URI 的内容,而无需提交另一个请求来检索图像或将图像嵌入页面两次。
作为页面内容的一部分,还有第二个额外的好处:作为 web 服务器 gzip 压缩的一部分,图像数据从服务器发送到客户端时会被压缩。
注
并非所有浏览器都支持数据 URIs。如果您选择在您的网站中使用数据 URIs,请确保您的目标市场的主要浏览器支持它们。
内容交付网络
一个 内容交付网络 ( CDN )是一个分布式服务器网络,只为返回静态内容而存在。cdn 可以通过托管通常缓存的静态内容并减少应用为任何给定请求发送和接收的数据量来减少网络负载。
缓存数据
如果您正在使用常见的第三方库(如 jQuery),执行您的应用的移动设备可能已经从第三方 CDN 加载了该库。如果设备已经检索到您想要加载的数据,客户端就不需要再次从服务器检索它。它可以简单地从缓存中加载它。有几个免费的 CDN 网络可用于公共内容。截至本文撰写之时,微软在其 CDN 上托管了大量常见的第三方内容,其中一个列表可能在http://www.asp.net/ajaxlibrary/cdn.ashx找到。
作为日常维护,您需要确保您用于共享内容的 CDN 继续提供内容。如果他们删除内容,你的应用最终会降级或失败。
车流量减少
一个 CDN 对你的专有静态内容也很有用。如果您在站点内使用 cookie,那么对 cookie 中指定的域的每个 HTTP 请求都将重新传输 cookie 数据。静态内容不需要这些数据,它消耗的带宽可以用在其他地方。如果您将站点的静态内容移动到 cookies 所在域之外的其他域,则可以减少发送到应用和从应用接收的数据量。
类型
不要让他们等
虽然限制用户等待加载应用内容的时间至关重要,但移动网络应用尤其如此。
呈现约束
处理约束和网络约束有助于定义如何实现后台服务,但通常是用户看到的东西定义了他们的体验。当向用户展示应用的内容时,您需要记住,在如何向用户展示信息方面存在非常现实的限制。
单窗
首先,你只有一个可以工作的窗口。这意味着您不应该创建需要弹出窗口的内容。弹出窗口将在大多数移动浏览器的新标签中打开,这些浏览器在任何给定时间打开的标签数量可能有限制。
坚持使用简单的导航模式要好得多,并且减少您在任何给定时间向用户呈现的数据量。用户可能会有一些更多的屏幕触摸来浏览你的应用,但是如果你使行为一致,那么你的用户不太可能会注意到。
更低的分辨率
除了市场上最新的移动设备之外,大多数设备都没有桌面设备的分辨率。
对比标准手机外形,iPhone 5 的屏幕分辨率为 1136 x 640 像素,三星 Galaxy S3 的分辨率为 1280 x 720。在受欢迎的 7 英寸平板电脑中,Kindle Fire HD 和谷歌 Nexus 7 的屏幕分辨率都是 1280 x 800。只有最大的平板电脑,如 10 英寸的第三代 iPad (2048 x 1536)和 8.9 英寸的 Kindle Fire HD (1920 x 1200),才能接近台式机的显示能力。
相比之下,iPhone 4 和 iPhone 4S 的分辨率为 960 x 640。
虽然这些分辨率对于移动设备来说似乎是值得尊敬的,但您必须记住,这些分辨率呈现在比桌面显示器小得多的屏幕上,这意味着不仅应用在这些较小的显示器上可用的像素数量减少了,而且您的应用需要呈现比桌面浏览器更大的内容、文本和按钮。这部分是因为移动设备的像素密度增加,部分是因为这些设备的输入机制是用户的手指。
内容间距
设计一个支持触摸而不是传统的鼠标和键盘输入方式的系统意味着你的按钮需要更大,更大的填充区域必须围绕屏幕上为与用户交互而设计的任何区域。这意味着您的用户界面和用户体验必须占很大比例的空间。
查看移动网络
虽然我们大多数人拥有一个、两个,或者三个或更多的移动设备来浏览网页,但我们需要开发我们的移动网络应用来支持尽可能多的设备。
市场百分比
重要的是让我们看看正在使用什么技术来浏览移动网络,以便我们可以适当地定位我们的移动应用。目前,安卓和 iOS 主导着移动操作系统市场,但最新版本的视窗移动正在获得市场份额。支持这些操作系统上的通用浏览器应该足以满足大多数应用的需求。
浏览器变体和兼容性
一个人如何针对这些特定的浏览器?所有这些系统都允许在其上安装第三方浏览器,因此我们在考虑兼容性时不能将操作系统百分比作为唯一的决定因素。
幸运的是,虽然这些平台有多种浏览器可用,但我们必须关注的布局引擎屈指可数。
网络工具包
WebKit 是大部分网页的布局引擎。Safari、Chrome、安卓网络浏览器、Dolphin HD(流行的第三方安卓网络浏览器)、Blackberry Browser 6.0+,甚至是 PS3 软件的一个版本都使用 WebKit。如果您的目标是没有任何特定于供应商的扩展的网络工具包,那么您将支持一个巨大的网络部分。
三叉戟
互联网浏览器使用三叉戟引擎布局 HTML 内容。如果你做过任何视窗桌面开发,你可能知道这个引擎的名字是 MSHTML。
自 Internet Explorer 7 以来,三叉戟在每次发布 Internet Explorer 时都会收到一个新版本。Windows 和 Windows Mobile 共享相同版本的引擎。Internet Explorer 10 和 Internet Explorer Mobile 10 使用了 6.0 版的三叉戟引擎。
壁虎
gecko 从网景 6 开始就有了,是目前 Firefox,以及其他几个 Mozilla Foundation 项目中的布局引擎。
快点
Opera 浏览器和任天堂 DS/DSi 使用的是 Presto 引擎。该引擎仅作为 Opera 的一部分提供,但不可忽视。Opera 是移动网络上的主流浏览器,根据你所相信的统计数据,它仍然是目前使用的第二或第三大移动浏览器,拥有超过 20%的市场份额(目前没有一款浏览器超过 25%)。
模仿移动网络
由于我们将在台式机或笔记本电脑上实现我们的移动应用,我们将希望模仿我们瞄准的移动设备。我们可以通过在我们的开发机器上为每个平台安装仿真器,或者通过在我们的计算机浏览器中伪造移动浏览器体验来做到这一点。
移动设备和浏览器模拟器
移动设备模拟器为我们提供了在移动浏览器中测试应用功能的最佳方式,而无需访问物理移动设备。
歌剧
Opera 移动仿真器是所有仿真器中占地面积最小的。这在很大程度上是因为没有模拟移动操作系统。该安装支持各种设备和浏览器版本变体,允许您在提供 Opera Mobile 的任何设备上测试应用的外观和感觉。还有一个可选的蜻蜓歌剧院安装。蜻蜓允许你调试你的 CSS 和 JavaScript,并在模拟器中调整你的应用的性能。
歌剧手机模拟器可以在http://www.opera.com/developer/tools/mobile/下载。
安卓
安卓软件开发工具包在http://developer.android.com/sdk提供,附带有移动设备仿真器,您可以使用它在安卓平台上测试您的应用。该软件开发工具包需要您安装几个第三方工具,最值得注意的是 JDK 6,才能完全发挥作用。
iOS
如果您无法访问运行 OS X 的机器,您就无法使用苹果官方 SDK 工具模拟 iOS 环境。第三方模拟器确实存在,您可以通过咨询本地搜索引擎找到它们。苹果用户可以在 https://developer.apple.com/xcode/index.php 下载 iOS 模拟器作为 Xcode(苹果的 IDE)的一部分。
Windows Mobile
微软通过其 Windows Mobile SDK 提供了一套相当全面的工具。您可以在http://dev.windowsphone.com/下载安装 Windows Mobile SDK。
用户代理
在模拟器之外,我们查看多种浏览器和设备变体的移动网络的最简单方法是操纵桌面浏览器的用户代理。用户代理包含在浏览器与任何标准内容请求一起发送给网络服务器的 HTTP 头中。下面一行是 Internet Explorer 10 随每个请求提交给网络服务器的用户代理 HTTP 头的表示:
User-Agent: Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Trident/6.0)
改变用户代理对测试应用的益处微乎其微,这一点怎么强调都不为过。即使移动浏览器通常建立在桌面浏览器的布局引擎之上,你也不能认为这些引擎会表现相同。移动引擎通常是桌面引擎的端口,并且可能由于移动设备的限制而被更改或调整。它们甚至可能不是用同一种语言写的。由于这些问题,不能假设WebKit == WebKit。它没有。因此,在将产品推送到生产系统之前,您需要使用实际的移动设备以及您的移动 web 应用所针对的所有浏览器变体来测试您的应用。
说了这么多,让我们来看看如何设置 Internet Explorer、Safari 和 Chrome 的用户代理字符串,以便它们将自己标识为网络服务器,作为它们的移动版本。
模仿互联网浏览器手机
要从桌面上模拟 Internet Explorer Mobile,您需要访问开发人员工具。为此,您可以在互联网浏览器中按下 F12 或点击窗口左上角的设置轮,并从菜单选项中选择 F12 开发者工具。

然后,Internet Explorer 将显示一个包含 Internet Explorer 开发人员工具的可停靠窗口。在工具窗口中,从菜单中点击工具,然后选择更改用户代理字符串菜单选项。您将在 Internet Explorer 中看到一个预配置的用户代理字符串列表。您也可以通过选择自定义… 菜单选项来输入自定义用户代理字符串。

关闭所有浏览器窗口并退出进程后,Internet Explorer 将恢复为默认的用户代理字符串。当您使用 Internet Explorer 调试移动应用时,这一点非常重要。
模拟移动游猎
要在移动 Safari 中设置用户代理字符串,我们首先必须启用移动工具。为此,按下 Ctrl + 、或单击窗口左上角的设置图标,然后单击首选项… ,打开 Safari 的首选项面板。

在首选项窗口中,单击标记为高级的图标以显示高级首选项窗格,并确保勾选了菜单栏中标题为显示桌面菜单的复选框。

关闭窗口后,会有一个新的开发菜单可用。点击菜单并悬停在用户代理上将打开 Safari 内置支持的已知用户代理字符串。如果您没有看到您希望定位的用户代理字符串,您可以通过单击标记为其他… 的菜单项来提供自定义用户代理字符串。

需要注意的是,Safari 和 Internet Explorer 一样,会在您关闭浏览器窗口后将用户代理字符串的值恢复为默认值。
为手机模仿 Chrome
和 Safari 一样,Chrome 也有内置的开发者工具,通过按Ctrl+Shift+I或者点击右上角的自定义图标,选择工具,然后点击开发者工具菜单项,即可立即访问这些工具。

点击开发工具菜单项将在浏览器底部显示一个停靠窗口。在这个窗口的左下角是一个小齿轮图标。单击图标将在一个覆盖的窗口中显示开发人员工具的设置。窗口有三个选项卡:常规、覆盖和快捷方式。单击覆盖选项卡,选中标记为用户代理的复选框,然后从下拉菜单中选择您想要使用的用户代理字符串。

像 Safari 和 Internet Explorer 一样,您可以创建供浏览器使用的自定义用户代理字符串。与那些浏览器不同,如果关闭所有窗口,谷歌 Chrome 会记住要使用的用户代理字符串。
本书中的仿真
当使用用户代理仿真时,本书中的所有示例都将从 Chrome 内部仿真 iOS 使用的移动 Safari 浏览器。这在很大程度上是由于 Chrome 使用了与 Safari 相同的布局引擎,但主要是由于浏览器本身内置的开发工具,以及它比 Safari 更广泛地安装在运行 Windows 的计算机上。
当使用移动模拟器时,我们将使用 Opera Mobile 模拟三星 Galaxy S II——一款屏幕分辨率为 480 x 800,每英寸 216 像素的设备。
我们还将展示一些物理设备的屏幕,如 iPhone 4、iPhone 5 和华硕 Nexus 7 安卓平板电脑。
您应该可以在上面提到的任何浏览器或模拟器上运行书中的示例。
支持 ASP.NET MVC 4 中的移动 web
微软凭借 Visual Studio 2012 和 ASP.NET MVC 4.0 对移动网络有着前所未有的开发支持。现成的最新环境支持:
- HTML5 和 CSS3(开发响应性移动网络应用的关键标准)
- 能够合并、压缩和转换 JavaScript 和 CSS 文件,以最大限度地减少浏览器请求和带宽需求
- 针对特定移动平台的新约定支持
- 作为新移动应用项目模板的一部分,jQuery Mobile 集成到您的移动应用项目中
所有这些对微软开发环境的改进直接解决了我们作为开发人员、工程师、架构师和内容设计人员必须关注的约束。如果将这些改进与.NET 4.5,。像我们这样的网络用户现在可以比以往任何时候都更好地瞄准移动网络。
总结
在这一章中,我们了解了为什么作为开发人员,拥抱移动网络并确保我们的应用支持它是很重要的。我们首先简要介绍了移动网络的历史,以及像我们这样的开发人员在通过移动浏览器查看我们的网站时仍然面临的限制,以确保最佳的用户体验。我们还学习了如何从我们的桌面模拟常见的移动浏览器,并了解了微软通过新的 ASP.NET MVC 4 工具 Visual Studio 2012 和提供了什么来支持移动网络.NET 4.5。
在下一章中,我们将为我们的应用——一个名为“酿酒”的自制配方共享应用——创建外壳,并配置我们的环境以在模拟器中运行该应用。
二、酿酒和你
在这一章中,我们将讨论你需要知道的一切,以建立我们的样本应用,一个酿酒的食谱分享网站。为了理解示例应用的领域,我们将从讨论 homebrewing 开始。根据我们对领域的理解,我们将确定需求。然后,我们将使用一个新的 MVC 4(模型-视图-控制器)项目模板为我们的应用创建解决方案,并检查模板的输出,以讨论 MVC 3 和 MVC 4 之间的显著变化。最后,我们将配置 Visual Studio 在启动我们的应用时同时启动模拟器和桌面浏览器。
了解酿酒领域
麦芽的酿造和发酵创造了通常被称为啤酒的美味饮料,这种情况从古埃及就开始了。啤酒由四种成分组成:水、麦芽、啤酒花和酵母。这四种成分的简单组合可以生产各种各样的饮料,正是这种对多样性的追求引发了今天如此活跃的酿酒和手工酿造运动。
了解你的食材
任何好的食谱都是从一份配料清单开始的。啤酒配方将从要使用的谷物、任何辅料(特殊成分,如咖啡、巧克力、水果或香料,可赋予啤酒新的风味或增强啤酒现有的特性)、要使用的酵母菌株和用于苦味和香味的啤酒花的清单开始。
麦芽
麦芽是任何被允许开始发芽过程的谷物。正是谷物的发芽使谷物产生酶,这些酶可以将谷物中的淀粉转化为糖,并分解蛋白质来喂养酵母。正是麦芽中转化的糖赋予了啤酒甜味,也正是这种糖在发酵过程中转化为酒精。
酵母
酵母处理糖向酒精的转化。酿造啤酒时,必须始终考虑酵母是活的。除了对酿造设备进行消毒之外,正确处理酵母对于酿造一批成功的麦芽酒或淡啤酒至关重要。
淡啤酒对淡啤酒
啤酒要么被归类为淡啤酒,要么被归类为淡啤酒。分类是基于用来发酵啤酒的酵母类型。啤酒酵母从发酵室的底部发酵啤酒,并且在比用来生产麦芽酒的酵母更低的温度下具有活性。麦酒酵母从顶部发酵,在华氏 70 度左右最活跃。麦芽酒酵母较高的发酵温度比贮藏酵母(T2)需要更少的特殊设备,因此,大多数酿酒配方都是为酿造麦芽酒而设计的。
啤酒花
啤酒花是一种用在食谱中的花,具有大多数人联想到啤酒的独特的香气和苦味。就像谷物和酵母一样,啤酒花有好几个品种,每一个品种都是根据配方中的香味和苦味来衡量的。
啤酒花也有助于防止啤酒腐败,因为它们在酿造过程中创造了一个有利于发酵啤酒的酵母的环境。
酿造
酿造过程将我们的原料转化为未发酵的啤酒。酿造过程有三个步骤,即捣碎、喷射和煮沸。
捣碎
糖化是在温度控制的环境下,将破碎的麦芽浸泡在水中的过程。温度控制很重要,因为不同的蛋白质、酶和副产物会根据麦芽保持的温度从麦芽中产生和释放出来。啤酒酿造过程中的这些副产品会影响啤酒的酒体、余味、澄清度和酒精含量。
捣碎过程可以是单次浸泡或分步浸泡。单次浸泡捣碎在整个过程中保持配料在单一温度。然而,分步浸泡需要酿酒师在整个糖化过程中改变配料的温度,以更好地控制蛋白质、酶和麦芽副产品的释放。
喷射
喷射是从糖化醪中分离用过的谷物(称为谷物)的过程,主要是将糖化醪过滤到另一个容器中。将额外的水倒在谷物上,以释放任何残留的淀粉、蛋白质或糖。产生的液体叫做麦芽汁。
沸腾
一旦你得到了麦芽汁,麦芽汁必须煮沸。煮沸通常持续 60 分钟,这意味着对液体进行消毒,为酵母创造最佳环境。啤酒花是在煮沸过程中加入啤酒的。
在煮沸的啤酒中提前加入啤酒花会增加啤酒的苦味。后来的添加增加了啤酒的香味。
发酵
在煮沸后,麦芽汁必须快速冷却,通常是到 70 华氏度,然后我们才能加入酵母。冷却后,将麦芽汁移入发酵罐,加入酵母。麦芽汁然后被保存在一个安静的角落里,温度对用来发酵的酵母菌株有利,通常保存两周。
装瓶和勾兑
我们现在有啤酒;这是纯啤酒,但还是啤酒。为了让啤酒更有活力,我们需要在将啤酒装入瓶子或小桶之前加入酵母。为了做到这一点,我们在啤酒或瓶子或小桶里放一点启动糖。给酵母喂糖会产生二氧化碳,二氧化碳的产生会使我们的啤酒碳酸化。
这就是制作啤酒的全部。两周后,在瓶子或小桶里,啤酒应该有足够的碳酸气,可以打开来和你的朋友分享。
现在我们对啤酒有了更深的理解、欣赏和热爱,让我们去构建一个应用,让我们可以将食谱分享给世界上的移动设备。
关于我们的手机应用
我们刚到酿酒供应店,结果,我们把配方忘在家里了。这是最后一次了。我们将建立一个应用,允许我们在手机上检索食谱。
在我们的应用中创建和存储的食谱也将为我们提供一种方法来教其他人关于酿造过程和什么是好啤酒。为此,我们将这个应用命名为 BrewHow。
App 要求
对于我们来说要成功完成为移动网络创建一个伟大的啤酒配方网站的任务,我们需要知道当满足时将决定成功的要求。
添加、编辑和删除食谱
没有菜谱添加编辑功能的菜谱分享 app 有多好?我们的网站将允许我们创建可以与其他人分享的食谱。它还将允许我们编辑我们贡献给网站的食谱。
将食谱添加到库中
当我们找到好的食谱时,我们会希望能够再次找到它们。我们需要支持将它们添加到库中。我们也可能会厌倦它们,所以我们也需要去除它们的能力。
评级食谱
正如我们评价音乐是为了更好地识别我们的口味一样,我们也应该用啤酒来做这件事。我们的应用将为用户提供从一到五的食谱评分能力。
点评菜谱
我们不仅想分享食谱,还想征求关于什么是好啤酒以及如何让我们的啤酒变得更好的反馈。为了允许这种类型的反馈,我们希望允许对我们网站上的食谱进行建设性的评论。
匿名浏览,认证投稿
我们希望我们的食谱可以在我们的移动网络应用中提供给全世界,但是如果一个用户想为这个网站做贡献,我们想知道这个用户是谁。同样,我们不希望用户编辑其他用户贡献的食谱。似乎我们需要在手机应用中增加一些认证和授权。
现在我们知道了我们的应用应该做什么,让我们开始创建我们的 ASP.NET MVC 4 解决方案。
酿酒方案
为了构建 brewerhow 移动应用,我们将使用 Visual Studio Express 2012 进行 Web。如果您没有能够构建 ASP.NET MVC 4 项目的 Visual Studio 版本,则可以在http://www.microsoft.com/visualstudio/eng/downloads免费获得 Visual Studio Express 2012 for Web。
注
我们会不断参考 Visual Studio 或 Visual Studio 2012。这些是对能够构建 ASP.NET MVC 4 应用的任何版本的 Visual Studio 2012 或 Visual Studio 2010 SP1 的引用。
创建项目
我们将从在 Visual Studio 2012 中创建新的解决方案开始。首先启动 Visual Studio。点击文件菜单,然后点击新项目……。也可以按Ctrl+Shift+N。

然后,Visual Studio 将提示您一个对话框,询问您要创建的解决方案类型。我们将创建一个新的 ASP.NET MVC 4 网络应用。

选择ASP.NET MVC 4 Web 应用图标,在名称和位置文本框中分别提供新项目的名称和位置。在本书中,我们将参考位于 C:上的 Packt 目录中的 BrewHow 项目,但是您可以将其命名为任何您喜欢的名称,并将其放在任何您想放的地方。当您决定了名称和位置后,点击确定。
Visual Studio 现在将提示您选择项目模板。
选择我们的模板
Visual Studio 2012 附带了 6 个 ASP.NET MVC 4 项目模板。
空模板
空模板并不像你想象的那么空。项目模板不是空的,而是包含使其成为 MVC 项目所需的最少内容。没有脚本或内容,Models、Controllers、App_Data文件夹为空。
基本模板
基本模板本质上是新版本的 MVC 3 空模板。该模板将Content和Scripts文件夹添加到空模板中,并包括 MVC 4 的一些新功能所需的附加组件引用。
互联网应用模板
这个模板将是模板,大多数网站将从这个模板创建。该模板包含基本模板中的所有内容,但在结构中添加了一个Account和Home控制器,以支持针对传统的 ASP.NET SQL Server 成员结构的身份验证,并且,对于 MVC 4 来说,第三方身份验证提供商(如微软、谷歌和脸书)通过 DotNetOpenAuth 库进行身份验证是新的。
内部网应用模板
内部网应用模板是互联网应用模板的变体。它已被更改为支持 Windows 身份验证作为身份验证机制。
移动应用模板
如果您确定几乎所有的流量都将来自移动设备,您将希望从移动应用模板创建您的应用。该模板将对 jQuery Mobile 的支持添加到互联网应用中,但删除了 DotNetOpenAuth 库支持,转而支持传统的表单身份验证。
网络应用编程接口模板
ASP.NET MVC 4 的新内容是网络应用编程接口。网络应用编程接口提供了一种开发 RESTful HTTP 服务和理解 XML 和 JSON 内容类型的应用编程接口的简单方法。这个项目模板为利用网络应用编程接口构建新服务提供了基础。
我们将从互联网模板创建我们的示例应用。我们这样做有几个原因。首先,我们日常实现和支持的大多数应用都要求我们既要针对桌面浏览器,也要针对移动浏览器。第二,通过从互联网模板进行开发,我们可以从 ASP.NET MVC 4 的角度以及一般应用开发的角度,了解更多支持移动 web 的必要条件。

从模板选项中选择互联网应用,点击标有确定的按钮。
恭喜你!你刚刚创建了你的第一个 ASP.NET MVC 4 项目。
MVC 4 中的项目变更
除了新的框架特性之外,ASP.NET MVC 4 项目也经历了一些重要的变化,如果你过去与 ASP.NET MVC 合作过,你应该注意到这些变化。
纽集
如果你不熟悉 NuGet ,NuGet 是一个. NET 平台的包管理系统。其目标是简化第三方库、工具和脚本的管理.NET 项目。它现在是 Visual Studio 2012 和 MVC 项目模板的一级成员。
如果您检查我们刚刚创建的项目,您会注意到在解决方案的底部有一个packages.config文件。

该文件是一个 NuGet 包列表,包含项目所有外部依赖项的列表。对于互联网应用模板,该文件包括对 DotNetOpenAuth、实体框架、jQuery、knockoutjs、非核心微软库、Modernizr、Netwonsoft 的 JSON 库和 WebGrease 的引用。
Global.asax
在以前的版本的 MVC 中,所有的应用引导代码都位于Global.asax代码后面。在 MVC 4 中,你会注意到Global.asax文件中的类只包含一个名为Application_Start的方法。看看下面这段代码:
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
AuthConfig.RegisterAuth();
}
这个方法调用其他几个你可能不熟悉的类。虽然关于捆绑和网络应用编程接口的一些新功能确实反映在类名中,但这些名称也标识了以前版本中已经存在的功能,例如区域注册和路由。这些新类封装了您可能期望在这里看到的传统配置信息,并且这些类存在于项目的App_Start文件夹中。

这种新的代码组织提供了更好的功能分离,并使Global.asax代码隐藏更容易阅读和消化。
现在我们已经看到了在新的 MVC 4 项目模板中发生的一些结构变化,让我们通过从 Visual Studio 2012 中启动项目来检查新项目模板的输出是什么样子的。
启动酿酒应用
要启动应用,只需按下 Ctrl + F5 即可启动应用,无需运行调试器。

您会注意到 MVC 应用的默认主题已经被重新设计。不仅初始应用看起来更好,而且它的结构也支持响应设计。
响应性设计
响应设计是指应用被设计为试图在任何浏览器窗口中正确呈现自己,并持续响应浏览器窗口大小或页面本身显示的内容的变化。

请注意浏览器窗口的内容是如何重新组织以在较小的浏览器窗口中工作的。所有内容仍然是逻辑分组的,并且对用户可用,并且不存在水平滚动条来指示虽然内容可能已经折叠,但它仍将显示在窗口内。
这种压缩视图确实可以让你了解网站在移动设备上的外观,但是如果你真的想知道它的外观,那么你应该在模拟器中查看应用。
配置和启动仿真器
从 Visual Studio 2012 中启动我们的应用,并让一个仿真器或多个仿真器打开登录页面,这将非常有用。对我们来说幸运的是,Visual Studio 2012 支持在尝试运行或调试应用时同时启动多个浏览器。
类型
选择并使用仿真器
希望您已经安装了第一章中列出的模拟器之一。如果没有,让我再次强调,除非你是在物理硬件(首选)或模拟器上测试应用。这对用户是有害的,因为它有问题,对你也是有害的,因为它玷污了你的声誉。
要将 Visual Studio 配置为同时启动多个浏览器,我们首先需要向解决方案中添加一个空的 HTML 页面。空的 HTML 页面不会添加任何功能,它的存在只是为了让我们能够访问上下文菜单,这将允许我们为我们的应用设置默认浏览器。
要向项目中添加新的 HTML 页面文件,右键单击酿酒项目,调出项目上下文菜单。从菜单中选择添加,然后选择 HTML 页面。

您将看到一个对话框,要求您指定项目的名称。命名文件任何你想要的(样本项目文件名为browser.html),然后点击标有确定的按钮。
右键单击刚刚添加到项目中的 HTML 页面,并从上下文菜单中选择浏览方式… 。

这将调出 Visual Studio 2012 用浏览对话框。此对话框允许我们设置默认浏览器,当从 Visual Studio 中启动应用时,我们将使用该浏览器查看应用。

我们将使用谷歌浏览器和 Opera 移动模拟器作为酿酒项目的默认浏览器。
注
虽然我们正在配置 Opera Mobile 来启动,但这些指令可以用于任何仿真器或桌面浏览器。
由于 Opera Mobile 不是一个通常在 Visual Studio 注册的浏览器,我们首先需要告诉 Visual Studio 在哪里可以找到它。点击用浏览对话框中的添加… 按钮,开始注册浏览器。

假设您已经在默认位置安装了 Opera Mobile 仿真器,在对话框中输入以下值,为运行在三星 Galaxy S II 上的 Opera Mobile 注册一个仿真器,然后点击确定。
|田
|
描述
|
| --- | --- |
| 程序 | C:\Program Files (x86)\Opera Mobile Emulator\OperaMobileEmu.exe |
| 争论 | -windowsize 480x800 -ppi 216 -profile-name "Samsung Galaxy S II" |
| 友好的名字 | Opera Mobile |
您现在应该会看到用浏览对话框中显示的歌剧手机。
按住 Ctrl 键,同时点击谷歌 Chrome 和 Opera Mobile ,点击标有设为默认的按钮,然后关闭对话框。

如果你成功了,你应该注意到 Visual Studio 中开始调试按钮旁边的文字现在显示多浏览器。

按下 Ctrl + F5 无需调试即可启动该应用,Visual Studio 将在 Chrome 和 Opera Mobile 模拟器中打开 BrewHow 应用。

总结
现在,您应该对 homebrewing 领域有了足够的了解,可以将其应用到示例应用中。我们还了解到 Visual Studio 2012 的 MVC 4 项目模板和项目结构中发生的变化,现在已经正确配置了您的环境,开始开发我们的移动自制配方共享应用 BrewHow。
在下一章中,我们将详细检查模板生成的代码,以及这些代码如何与 MVC 设计模式相关联。我们还将使用这些知识创建我们的第一个控制器。
三、ASP.NET MVC 4 简介
ASP.NET MVC 4 是微软网络模型-视图-控制器(MVC)框架的最新版本。MVC 模式并不特定于这个框架。它最早是在 20 世纪 70 年代作为 Smalltalk 语言的一个特性引入的,并在客户端/服务器、桌面、移动和网络开发中取得了巨大成功。这是一种软件设计模式,有助于在数据和与数据的交互之间实现关注点的分离。
在本章中,我们将研究 MVC 模式,以及它是如何在 ASP.NET MVC 框架中实现的。然后,我们将从将这些知识应用到酿酒应用开始。在本章的最后,我们的应用将向任何提出请求的用户返回示例食谱。
模型-视图-控制器模式
MVC 模式的每个组件都有一个非常具体的用途,将应用中的数据与用户与数据的交互分离开来。下面是对 MVC 设计模式组件的非常简单的介绍。
控制器
在 MVC 模式中,控制器充当委托者。它代表一些外部交互(通常是用户)向模型提交修改,并通过用户交互作为通知或直接请求的结果检索视图的数据。
视图
视图处理将数据呈现给某个外部实体。如果一个视图包含逻辑,那么该逻辑仅限于作为与模型交互的结果从控制器接收的数据的表示。
模型
模型是特定于应用的数据的封装,以及存储、检索和维护该数据完整性的方法。该模型可能模仿也可能不模仿存储或呈现实际数据的结构。
MVC 格局和 ASP.NET MVC 4
ASP.NET MVC 4 中的 MVC 模式的实现很大程度上遵循了一个约定胜于配置的范例。简而言之,如果你把某样东西放在正确的地方和/或用正确的方式命名它,它就简单有效。这并不是说我们不能配置框架来忽略或改变这些约定。正是 ASP.NET MVC 4 的灵活性和适应性,以及它对 web 标准的坚持,推动了它的快速采用。
注
如果你没有接触过 ASP.NET 或 ASP.NET 的 MVC,请注意,MVC 模式的每个组成部分,因为它们与 ASP.NET MVC 4 相关,经常在单独的一章中出现。
以下是 ASP.NET MVC 4 中 MVC 模式的一个非常浓缩的高级概述。
ASP.NET MVC 中的控制器
在 ASP.NET MVC 4 中,控制器响应 HTTP 请求,并根据传入请求的内容确定要采取的操作。
ASP.NET MVC 4 中的控制器位于一个名为Controllers的项目文件夹中。我们为酿酒厂选择的互联网应用项目在Controllers文件夹中包含两个控制器:AccountController和HomeController。

如果您检查HomeController.cs文件中的代码,您会注意到HomeController类扩展了Controller类。
public class HomeController : Controller
{
/* Class methods... */
}
类型
下载示例代码
您可以从您在http://www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问http://www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
Controller类和ControllerBase类包含用于处理和响应 HTTP 请求的方法和属性,其中Controller类是从这两个类派生的。从请求中检索数据或响应请求返回数据时发生的几乎所有交互都将通过Controller继承链公开的方法和属性进行。
如果您之前没有使用 ASP.NET MVC 的经验,您可以假设项目中的控制器是通过放置在Controllers文件夹中或者通过扩展Controller类来识别的。实际上,运行时使用类名来标识默认 MVC 框架配置中的控制器;控制器必须有一个Controller后缀。例如,食谱可能有一个控制器,代表这个控制器的类将被命名为RecipeController。
注
正如我们简要讨论的,ASP.NET MVC 框架的每个部分都是可扩展和可配置的。您可以自由地向框架提供一些其他约定,框架可以使用这些约定来标识控制器。
创建配方控制器
让我们创建一个新控制器,将食谱返回给我们的用户。在解决方案浏览器中,右键单击酿酒项目中的控制器文件夹,选择添加,然后选择控制器菜单项。也可以按 Ctrl + M , Ctrl + C 。

在添加控制器对话框中,命名控制器RecipeController,从模板下拉菜单中选择空 MVC 控制器模板,然后点击添加按钮。

恭喜你!您已经在酿酒应用中创建了第一个控制器。代码应该如下所示:
public class RecipeController : Controller
{
public ActionResult Index()
{
return View();
}
}
请注意,该代码与HomeController中的代码非常相似。RecipeController继承了Controller类,并且像HomeController一样,包含一个返回ActionResult的Index方法。该类也用Controller后缀正确命名,因此运行时将能够将其识别为控制器。
我相信您已经准备好检查这个控制器的输出,但是在我们这样做之前,我们需要先了解一下控制器是如何被调用的。
路由介绍
在应用启动时,一系列格式化的字符串(类似于您将在String.Format)中使用的格式化字符串)以及名称、默认值、约束和/或类型被注册到运行时。名称、格式化字符串、值、约束和类型的这种组合称为路由。运行时使用路由来确定应该调用哪个控制器和操作来处理请求。在我们的酿酒项目中,路由是在App_Start文件夹的RouteConfig.cs文件中定义并注册的。
我们的应用的默认路由在运行时注册,如下所示。
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new
{
controller = "Home",
action = "Index",
id = UrlParameter.Optional
});
我们默认路由的格式化字符串看起来非常类似于您可能在浏览器中键入的网址。唯一值得注意的区别是,网址的每一段——斜线(/)之间的网址部分——都用大括号({})括起来。
我们的默认路由包含用于标识控制器、操作(方法)和可选标识的段。
当我们的应用收到请求时,运行时会查看请求的网址,并尝试将其映射到通过MapRoute方法注册的任何格式化字符串。如果传入的 URL 包含可以映射到格式化字符串 URL 的段的段,则运行时确定找到了匹配项。
两个段controller和action有特殊的含义,决定了要实例化的控制器和要在控制器上调用的方法(动作)。其他段可以映射到方法本身的参数。所有段都放在路由字典中,使用花括号内的段名作为关键字,URL 的映射部分作为值。
通过一个例子来帮助澄清这一点,假设向我们的应用请求网址/do/something。运行时会查询路由表,它是应用注册的所有路由的集合,并在我们的Default路由上找到匹配的。然后,它将开始将 URL 请求的段映射到格式化的字符串 URL。值do将放在路由字典中的controller键下,值something将放在action键下。然后,运行时将尝试调用DoController的Something方法,将Controller后缀附加到控制器名称上。
如果您进一步检查我们的Default路由,您会看到花括号内的单词对应于通过MapRoute的defaults参数传递的值的名称。事实上,这就是默认路由的原因。如果没有控制器被路由识别,那么运行时将使用HomeController。如果找不到任何动作,将使用已识别控制器上的Index方法。我们在第 2 章、酿酒和你中看到的欢迎页面被调用,是因为所请求的/(斜杠)的网址映射到了我们的Default路由,因此调用了HomeController的Index方法。
注
路由是一个非常复杂的话题。传入路由可以忽略,可能包含特定的值或约束作为其映射的一部分,并且可能包含可选参数。别忘了,路由注册的顺序很重要,非常重要。路由是如此复杂,以至于整个工具都是围绕调试路由问题而构建的。本介绍仅涵盖理解本书内容所需的信息,直到我们到达第 7 章、使用路由和区域分离功能。
动作方法
一个动作方法(简称动作)是一个 ASP.NET MVC 控制器中的一个特殊方法,用来做举重。动作方法中的代码根据传递给它的数据处理业务决策,代表请求与模型交互,并告诉运行时在对客户端的响应中应该处理哪个视图。
在对我们的应用的初始请求中,当我们检查路由如何工作时,我们识别出HomeController类被调用并被指示执行Index动作。
对于我们的配方控制器,这个方法非常简单。
public ActionResult Index()
{
return View();
}
在我们的行动方法中没有模型交互,也没有业务决策要做。动作所做的只是使用基本Controller类的View方法返回默认视图。View方法是基础Controller类中返回ActionResult的几种方法之一。
动作结果
一ActionResult 是动作方法使用的特殊回位类型。ActionResult允许您将视图呈现给响应,将请求者重定向到另一个资源(控制器、视图、图像和其他资源),将数据翻译成 JSON 用于 AJAX 方法调用,以及几乎任何您可以想象的东西。
基础Controller类提供了返回大部分您需要的ActionResult派生类的方法,所以您应该很少需要实例化一个从ActionResult派生的类。下表说明了基本Controller类中返回ActionResult的辅助方法:
控制器操作结果帮助器
|
返回
|
使用
|
| --- | --- | --- |
| Content | ContentResult | 响应控制器上的请求返回内容 |
| File | FileContentResult``FileStreamResult``FilePathResult | 响应控制器上的请求返回文件的内容 |
| JavaScript | JavaScriptResult | 响应控制器上的请求返回 JavaScript |
| Json | JsonResult | 响应控制器上的请求,返回对象的 JSON 表示,通常是模型或视图模型 |
| PartialView | PartialViewResult | 响应控制器上的请求,返回呈现的部分视图结果 |
| Redirect | RedirectResponse | 指示请求者从另一个位置检索请求的资源 |
| RedirectPermanent | RedirectResponse | 指示请求者从另一个位置检索所请求的资源,并且该资源已被永久移动 |
| RedirectToAction | RedirectToRouteResult | 行为与重定向响应相同,但使用路由表来创建返回给请求者的重定向位置 |
| RedirectToActionPermanent | RedirectToRouteResult | 这与 RedirectToAction 相同,但通知请求者资源已被永久移动 |
| RedirectToRoute | RedirectToRouteResult | 获取路由参数,即映射到路由表中 URL 段的值,以构造一个返回给请求者的重定向 URL |
| RedirectToRoutePermanent | RedirectToRouteResult | 行为与 RedirectToRoute 相同,但通知请求者资源已被永久移动 |
| View | ViewResult | 响应控制器上的请求,返回呈现的视图结果 |
调用配方控制器
应用我们所了解的路由,访问/Recipe应该使用Default路由调用我们新的RecipeController的Index动作方法。在浏览器中启动应用,将/Recipe附加到网址上,然后按进入。

这可能不是你所期望的结果。该错误的存在是因为没有可用于RecipeController的Index动作的视图。
ASP.NET MVC 中的视图
在 ASP.NET MVC 和通用的 MVC 模式中,视图处理作为发送给控制器的请求的结果的数据表示。默认情况下,视图存储在Views文件夹中。

在项目的Views文件夹中,有对应于每个控制器的文件夹。注意在Home文件夹中有对应于HomeController类中每个动作方法的视图。这是设计上的,也是 ASP.NET MVC 的另一个超越配置的特性。
每当运行时寻找与动作相关联的视图时,它将首先寻找名称与被调用的动作相匹配的视图。它将尝试在一个文件夹中找到该视图,该文件夹的名称与被调用的控制器相匹配。对于我们最初的/Home/Index请求,运行时会寻找视图Views/Home/Index.cshtml。
注
运行时通过相当大的搜索顺序来确定默认视图。如果您在尝试调用RecipeController时检查出现的错误屏幕,而没有首先为操作创建视图,您可以看到该搜索顺序。
剃刀
Razor 是默认的视图引擎,在 ASP.NET MVC 4 中,它是运行时的一部分,用于解析、处理和执行视图中的任何代码。Razor 视图引擎有一个非常简洁的语法,旨在限制击键并增加视图的可读性。
注意,我说的是语法而不是语言。Razor 旨在轻松融入您现有的技能组合,让您专注于当前的任务。要成功使用 Razor,您真正需要知道的是@字符在键盘上的位置。
注
.cshtml是一个扩展,它将视图标识为用 C#编写的 Razor View Engine 视图。如果编写视图的语言是 Visual Basic,扩展将是.vbhtml。如果您选择使用网络表单视图引擎,您的视图将有一个与之关联的.aspx扩展,但是您将无法跟随本书中的示例。BrewHow 应用使用的所有视图都由 Razor View 引擎解析和处理。
字符@字符
@字符是理解 Razor 语法的关键。它是用于表示代码块、注释、表达式和内联代码的符号。更准确地说,@字符指示视图引擎开始将视图的内容视为必须解析、处理和执行的代码。
代码块
如果您想要在 Razor 视图中放置一个代码块,只需使用@为该代码块添加前缀。
@{
string foo = "bar";
}
代码块是大括号({})之间的代码部分。
表情
表达式是代码的一部分,其计算结果是返回值。表达式可以是方法调用。
@foo.Bar()
它们也可以用来检索属性值。
@user.FirstName.
表达式可以直接嵌入到输出的 HTML 中。
<h1>Hello, @user.FirstName</h1>
默认情况下,任何 Razor 表达式求值的结果都是 HTML 编码的。这意味着任何对客户端有意义的特殊字符都会作为输出处理的一部分被转义。该功能还充当安全机制,防止可执行代码的意外(或恶意)添加被发送给请求者。
如果需要公开属性或方法的原始值,可以使用Raw HTML 帮助器扩展方法。
@Html.Raw(beVeryCarefulDoingThis)
注
HTML 编码通过指示浏览器将输出呈现为数据来帮助防止跨站点脚本(XSS)攻击。对返回给客户端的所有数据进行编码是至关重要的。网站上还有其他几种可能的攻击,远远超出了本书的范围,我敦促您开发防御性编码实践,并了解您的应用可能面临的潜在威胁。
内嵌代码
正如前面提到的,Razor View 引擎足够智能,可以推断出一段可操作的代码何时已经终止,处理应该停止。在视图中放置内联代码时,这非常方便。
@if (userIsAuthenticated) {
<span>Hello, @username</span>
} else {
<a href='#'>Please login</a>
}
如果您想要在内嵌代码中向用户显示的内容没有包装标签,如span或div,您可以将内容包装在<text>标签中。
@if (jobIsDone) {
<text>profit!</text>
}
请注意,仅仅提及代码在视图中的位置可能会导致类似于关于左花括号位置的讨论。如果你是内联代码教会的实践成员,最好遵循“不问不说”的政策。
评论
如果你是明智的,并评论你的代码,Razor 支持它。
@* Your comment goes here. *@
评论和其他东西一样,需要维护。如果您修改代码,请修改您的注释。
Razor 还可以做其他一些事情,比如创建委托,但是我们将在应用中遇到委托时解决它们。
共享视图
当我们检查HomeController的视图时,我们了解到一个控制器的视图被放置在一个同名的文件夹中。视图只对拥有该文件夹的控制器可用。如果我们想让一个视图容易被多个控制器访问,我们需要将该视图标记为共享,方法是将该视图放在Views文件夹下的Shared文件夹中。

如果运行时在执行控制器的视图文件夹中找不到合适的视图,那么下一个位置将是Shared文件夹。我们当前的应用在Shared文件夹中有三个视图。
_LoginPartial.cshtml 是包含我们登录控件的局部视图。该控件嵌入在我们的几个视图中,并为未经身份验证的用户显示登录表单。几个视图对它的使用使它在Shared文件夹中占有一席之地。
视图Error.cshtml是全局错误页面。当不可预见的事情发生时,它通过行动方法返回。由于任何操作都可以返回该视图,因此它被放置在Shared文件夹中。
第三个视图_Layout.cshtml是一种特殊类型的共享视图,称为布局。
布局
布局是视图内容的模板。布局通常包含脚本、导航、页眉、页脚或您认为在网站的多个页面上需要的其他元素。如果你过去与 ASP.NET 合作过,你很可能熟悉母版页。布局是 ASP.NET MVC 世界的母版页。
您可以使用视图的Layout属性指定页面布局。
@{
Layout = "~/Views/Shared/_Layout.cshtml"
}
当一个视图被加载时,布局被执行,视图的内容被放置在调用@RenderBody()的地方。
视图开始文件
如果您有几个视图使用相同的布局,在每个视图中指定布局可能会有点重复。为了保持干燥(不要重复自己),您可以在_ViewStart.cshtml中指定网站所有视图的默认布局。
_ViewStart.cshtml位于Views文件夹的根目录。它用于存储应用中所有视图通用的代码,而不仅仅是指定布局。加载每个视图时,会调用_ViewStart.cshtml。它就像视图的基类,其中的任何代码都成为站点中每个视图的构造函数的一部分。
注
您会注意到_LoginPartial.cshtml和_Layout.cshtml都以下划线开头。下划线是另一种约定,表示不直接请求视图。
局部视图
部分视图允许您将视图的部分设计为可重用组件。这些与 ASP.NET 网页表单开发中的控件非常相似。
部分视图可以使用PartialView方法直接从控制器返回,或者直接返回PartialReviewResult返回类型。它们也可以使用RenderPartial、Partial、RenderAction或Action HTML 助手方法直接嵌入到视图中。
HTML 助手
您将在我们的视图中看到的许多代码都有以Html为前缀的方法。这些方法是HtmlHelper扩展方法(简称 HTML 助手),旨在为您提供一种快速的方法来执行一些操作或将一些信息嵌入到视图的输出中。通常,这些方法的返回类型为string。HTML 助手帮助标准化视图(如表单或链接)中呈现的内容的呈现。
Html。RenderPartial 和 Html。部分的
RenderPartial HTML 助手处理部分视图,并将结果直接写入响应流。结果就好像部分视图的代码被直接放入了调用视图。
@Html.RenderPartial("_MyPartialView", someData)
Partial HTML 帮助器对视图的处理与RenderPartial帮助器相同,但处理的输出以字符串形式返回。我们项目中的_LoginPartial.cshtml是使用Partial HTML 帮助器在_Layout.cshtml布局中渲染的。
@Html.Partial("_LoginPartial")
如果在局部视图中返回大量数据,RenderPartial会比Partial稍微高效一些,因为它与响应流直接交互。
Html。RenderAction 和 Html。行动
RenderAction HTML 帮助器与RenderPartial的不同之处在于,它实际上调用了一个控制器的动作方法,并将该内容嵌入到视图中。这一功能非常有用,因为它提供了一种方式来执行控制器中特定于视图的业务逻辑或其他代码,该控制器负责将视图返回给请求者。
假设我们决定在我们的啤酒之路网站的每个页面上展示一个最受欢迎的啤酒风格的标签云。

您有两个选项:您可以让站点的每个操作从数据库中检索或缓存当前值以显示在标签中,或者您可以让一个控制器操作单独负责检索标签云的值,并使用RenderAction或Action方法在每个视图中调用该操作。
RenderAction``Action的区别与RenderPartial和Partial的区别相同。RenderAction将处理的输出直接写入响应流。Action将处理的输出作为string返回。
显示模板
显示模板是类型特定的局部视图。它们存在于一个名为DisplayTemplates的文件夹中。如果显示模板特定于控制器,则该文件夹可能存在于控制器的View文件夹中,或者如果要由整个应用使用,则该文件夹可能存在于Shared文件夹下。
每个显示模板都必须根据其类型进行命名。应用中任何地方都可以使用的Beer类的显示模板将被命名为Beer.cshtml并放置在~/Shared/Views/DisplayTemplates中。
我们还可以为字符串或整数等基本类型创建显示模板。
可以使用Display、DisplayFor或DisplayForModel HTML 帮助器方法将显示模板添加到视图中。
Html。显示
Display HTML 帮助器 接受单个字符串参数。该字符串表示当前模型的属性名或ViewData字典中要渲染的值。
假设传递给我们的模型定义如下:
public class Recipe
{
public string RecipeName { get; set; }
/* Other properties here… */
}
如果我们想要调用RecipeName属性的显示模板,我们将以下代码放入视图中:
@Html.Display("RecipeName")
运行时将尝试找到字符串类型的显示模板,并使用任何已识别的模板来呈现RecipeName属性。
Html。DisplayFor
DisplayFor是的基于表达式的版本Display。它将视图的当前模型作为参数,并处理模型上指定属性的显示模板。
@Html.DisplayFor(model => model.RecipeName)
我们将在本章后面填充视图时研究视图如何知道模型类型。
html . display formdel
如果我们希望为整个模型调用显示模板链,我们只需使用 DisplayForModel辅助方法。
@Html.DisplayForModel()
编辑器模板
编辑器模板是显示模板的读/写版本。它们的实现和用法与显示模板相同。
编辑器模板存储在名为EditorTemplates的文件夹中。该文件夹可能与DisplayTemplates文件夹位于同一位置。
编辑器模板和显示模板一样,使用三种 HTML 帮助器方法之一来调用:Editor、EditorFor或EditorForModel。
我们将利用显示和编辑器模板来改进我们的酿酒应用。
创建我们的配方视图
走了这么远的路,我们可以回到手头的任务上了。我们现在知道,如果我们想要/Recipe网址成功,我们需要在~/Views/Recipe文件夹中创建一个名为Index.cshtml的视图。我们可以在解决方案中手动创建文件夹结构(当前不存在Recipe文件夹),然后创建一个新的局部视图,但是为什么不允许 Visual Studio 为我们做这项繁重的工作。
在解决方案资源管理器中打开RecipeController.cs文件,在Index动作方法中的任意位置单击鼠标右键。您将看到一个标签为添加视图… 的菜单项。

点击添加视图… 或按 Ctrl + M 、 Ctrl + V 将显示添加视图对话框。只需点击添加按钮,接受默认值。

如果您返回到解决方案资源管理器,您将看到视图目录结构和RecipeController的Index.cshtml视图。

像以前一样,通过按下 Ctrl + F5 和将/Recipe追加到浏览器地址栏中的网址来启动应用。现在,您应该会看到一个与以下截图非常相似的页面:

使配方默认
我们应该将/Recipe控制器的Index动作设置为应用的默认动作,因为我们的应用都是关于分享食谱的。
打开App_Start文件夹中的RouteConfig.cs文件,将defaults参数的值修改为默认的RecipeController。请记住,在寻找处理请求的类时,运行库会将Controller追加到控制器名称中。
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}",
defaults: new
{
controller = "Recipe",
action = "Index",
id = UrlParameter.Optional
});
按 Ctrl + F5 构建并启动应用。

我们的RecipeController的Index动作和视图现在处理向用户呈现默认登陆页面。剩下的唯一事情就是修改我们的布局,这样当用户点击主页导航链接或徽标文本本身时,他们会被重定向到/Recipe/Index而不是/Home/Index。
<p class="site-title">
@Html.ActionLink("your logo here",
"Index",
"Recipe")
</p>
<!-- Other stuff happens -->
<li>
@Html.ActionLink("Home",
"Index",
"Recipe")
</li>
将模型返回视图
我们已经成功处理了视图并将其返回给客户。现在我们需要从RecipeController的Index动作中返回一些数据。这些数据代表了 MVC 模式的模型部分,ASP.NET MVC 支持几种方法来返回控制器收集的任何数据。
使用视图数据
ControllerBase类的ViewData属性(T2】类的父类)是一种“神奇的字符串”机制,用于将数据从控制器传递到视图。它或多或少是一本字典。几乎所有与它的交互都是通过其IDictionary<string,object>接口实现提供的键/值语法进行的。
ViewData["MyMagicProperty"] = "Magic String Win!"
使用WebViewPage基类的ViewData属性,可以在视图中检索任何放入ViewData字典的值。
@ViewData["MyMagicProperty"]
从Controller和ControllerBase类中向视图发送模型的所有其他方法都是ViewData字典的包装和抽象。
虽然您可能(也可能不)将魔法字符串视为数据交换的适当机制,但所有其他将数据返回视图的方法都在内部利用ViewData。
使用视图包
ViewBag是ViewData字典的包装器,支持ViewData字典上的动态属性和类型。
如果我们检查类的Index动作,我们会看到它正在使用ViewBag属性向视图发送消息。
public ActionResult Index()
{
ViewBag.Message = "Modify this template to jump-start your ASP.NET MVC application.";
return View();
}
Message不是ViewBag属性的声明成员。ViewBag属性支持动态类型化,也称为鸭式类型化,允许为未在对象上声明的属性赋值。仅仅是给这些属性赋值的行为就可以将它们添加到对象中。
可以使用WebViewPage基类的ViewBag属性从视图中访问ViewBag数据。HomeController的Index视图有以下代码:
<hgroup class="title">
<h1> @ViewBag.Title. </h1>
<h2> @ViewBag.Message </h2>
</hgroup>
这将作为页面内容的一部分显示给用户。

ViewBag的任何使用都应记录在案。由于它们的动态特性,编译器无法验证对动态类型的引用。试图访问不存在的属性或方法,或者使用不同类型或参数定义的属性或方法,将不会在编译时被捕获。相反,这些错误将在运行时被捕获,并将导致您的应用崩溃。
使用温度数据
如果您需要在重定向或顺序请求之间保存视图数据,那么您可能需要考虑使用TempData。TempData 是ViewData词典的另一个包装,但是TempData中放置的数据是故意短命的;存储在TempData中的任何内容都将只保留两个请求:同一用户的当前请求和下一个后续请求。
将数据放入TempData字典应该谨慎进行,因为实现可能会导致意外行为。
假设我们应用的一个用户打开了两个标签来比较两种食谱。请求来自其中一个标签,将数据插入TempData进行消费。然后从另一个打开的选项卡触发对应用的第二个请求。第二个请求将强制由第一个请求放入TempData的值过期,无论预期的接收者是否已经检索并操作了这些值。
可通过ControllerBase和WebViewPage的TempData属性访问TempData字典。
TempData["UserNotification"] = "Hope you get this message."
@TempData["UserNotification"]
到目前为止介绍的三种方法提供了从控制器向视图提交松散耦合的数据的方法。虽然您当然可以通过这些机制向视图发送完整的模型,但是框架提供了一种强类型的方法来在控制器和视图之间移动数据。
强类型模型
如果您想要将强类型模型从控制器传递到视图,您可以通过两种方式之一来实现。您可以通过在ViewData字典上设置Model属性的值来直接实现这一点(好的,所以它不仅仅是一个字典),或者您可以通过Controller类上的一个ActionResult方法将模型传递给视图。
直接设置属性可以如下进行:
ViewData.Model = myModel;
您可能不会经常直接设置该值,而是会使用ActionResult方法之一。
return View(myModel);
这要简洁得多,如果您检查Controller类中的代码,您会看到它实际上是代表您设置ViewData.Model。
protected internal virtual ViewResult View
(string viewName, string masterName, object model)
{
if (model != null)
{
ViewData.Model = model;
}
/* Other code removed */
}
类型
主动出击
上面的代码直接取自 ASP.NET MVC 4 的 RTM 版本。微软的整个网络堆栈可以在aspnetwebstack.microsoft.com获得开源许可。我们敦促您花时间研究代码,了解框架实际上在做什么。
对于视图来说,要以强类型的方式操作这个模型,它必须被告知预期的模型类型。在 Razor 视图中,这是通过@model关键字完成的。
@model BrewHow.Web.Model.MyModel
返回配方列表
让我们把所学的一切付诸实践。当我们应用的用户登陆食谱页面时,我们会向他们展示一份食谱列表。
创建模型
我们需要定义一个模型来表示我们应用中的一个食谱。目前,该模型将包含四个属性:名称、样式、原始重力和最终重力。
注
液体的重力是相对于水的密度的量度,水的重力为 1.0。测量啤酒的重力是为了确定糖在液体中所占的百分比。酿造啤酒时要进行两次测量。在添加酵母之前,原始重力用于测量麦芽汁中的糖含量。最终重力用于测量发酵结束时液体的重力。这两个测量值之间的差异可以用来确定未发酵麦芽汁中有多少糖在发酵过程中转化为酒精。确定酒精体积含量的公式为 132.715*(原始重力-最终重力)。
在解决方案浏览器中,右键点击车型文件夹,选择添加,然后点击类……。

在添加新项目对话框中选择类作为要添加的项目类型(如果尚未选择)。命名类Recipe,点击添加创建类。

打开新的Recipe.cs文件,用以下内容替换Recipe类定义:
public class Recipe
{
public string Name { get; set; }
public string Style { get; set; }
public float OriginalGravity { get; set; }
public float FinalGravity { get; set; }
}
保存并关闭文件。
返回模型
打开的RecipeController类,用以下代码替换Index动作:
public ActionResult Index()
{
var recipeListModel = new List<Recipe>
{
new Recipe { Name = "Sweaty Brown Ale", Style = "Brown Ale", OriginalGravity = 1.05f, FinalGravity = 1.01f },
new Recipe { Name = "Festive Milk Stout", Style="Sweet/Milk Stout", OriginalGravity = 1.058f, FinalGravity = 1.015f },
new Recipe { Name = "Andy's Heffy", Style = "Heffeweisen", OriginalGravity = 1.045f, FinalGravity = 1.012f }
}
return View(recipeListModel);
}
新的Index动作相当简单。新代码创建一个填充列表,并将其分配给recipeListModel变量。填充的列表通过View方法作为强类型模型发送到Index视图(请记住,除非另有说明,否则该视图与动作同名)。
return View(recipeListModel);
对于要编译的代码,您需要在文件顶部添加一条using语句,以引用在BrewHow.Models中定义的新Recipe模型。
using BrewHow.Models;
显示模型
最后一步是告知视图即将到来的模型,并为视图提供显示模型的手段。
在~/Views/Recipe文件夹中打开Index.cshtml视图,并在视图顶部添加以下行。
@model IEnumerable<BrewHow.Models.Recipe>
这表明我们传递给视图的模型是一个食谱的枚举。它现在是强类型的。
将光标放在Index.cshtml视图的底部,粘贴以下代码。即使表格被恰当地用来显示表格数据,你也不会因为害怕使用表格而被评判。我们只是想让模型立即显示出来。
<table>
<tr >
<th>
@Html.DisplayNameFor(model => model.Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Style)
</th>
<th>
@Html.DisplayNameFor(model => model.OriginalGravity)
</th>
<th>
@Html.DisplayNameFor(model => model.FinalGravity)
</th>
</tr >
@foreach (var item in Model) {
<tr >
<td>
@Html.DisplayFor(modelItem => item.Name)
</td>
<td>
@Html.DisplayFor(modelItem => item.Style)
</td>
<td>
@Html.DisplayFor(modelItem => item.OriginalGravity)
</td>
<td>
@Html.DisplayFor(modelItem => item.FinalGravity)
</td>
</tr >
}
</table>
粘贴到视图中的标记会布局一个表,列出传递到视图的模型集合中的每个条目。一定要注意 Razor 语法对视图标记的干扰有多小。
表格的第一行包含配方列表的标题。在foreach循环中处理视图的过程中,视图引擎迭代传递给它的集合中的每个项目,并使用该类型的显示模板为表输出一行。由于我们没有定义显示模板,属性的值将简单地作为字符串输出。
您已经成功地创建了模型,在控制器中填充了模型,将填充的模型从控制器传递到视图,并向用户显示填充的模型。按下键盘上的 Ctrl + F5 即可启动 app,欣赏你的作品。

总结
恭喜你!这一章是信息的旋风,但是我们现在已经对 MVC 模式有了基本的了解,以及它与 ASP.NET MVC 4 的关系。我们学习了控制器和动作方法以及如何创建它们。我们创建了一个模型,将模型从控制器传递给视图,并编写了显示该模型的代码。了解了基本知识,我们就该看看更大更好的东西了。
在下一章中,我们将利用实体框架 5.0 的功能为我们的应用创建一个持久层,并返回存储在数据库中的信息。这个持久层将作为我们模型前进的基础。
四、在 EF5 中建模酿酒
如果你正在开发一个新的应用,微软非常希望你使用实体框架 5.0 ( EF5 )。实体框架是微软官方支持的对象-关系映射 ( ORM )工具,在经过了一段饱受诟病的介绍之后,终于独立出来了.NET 3.5 SP1。
与任何 ORM 一样,实体框架旨在将应用的域模型与实际的存储机制分开。这使得开发人员能够专注于他们试图解决的实际问题,并减少担心模型下的表和列的时间。
让框架提供底层存储机制确实有其自身的一系列问题。几乎任何数据库管理员都会告诉您,表单通常会生成不符合标准的存储模型,实体框架也不排除这个问题。然而,实体框架允许您定制域和数据库之间的转换,以匹配几乎任何可能的底层数据存储,从而允许您和您的数据库管理员相对和谐地工作。
在本章中,我们将使用实体框架 5 为 BrewHow 应用创建持久层。我们将探讨迁移以及如何使用它们来应用和删除对数据库的更改。此外,我们将查看实体框架 5.0 使用的一些约定和配置,以根据我们或我们的数据库管理员更喜欢的数据库映射来定制我们的模型。
实体框架 5.0 有什么新功能?
实体框架的 5.0 版本是对微软 ORM 的一个相当大的更新。虽然有一些改进和增加,我们将只简要地检查这些改进,因为它们与在我们的酿酒应用开发中使用 EF5 有关。
性能增强
在引擎盖下,微软已经把它能做到的每一分性能都挤出了框架。关键的性能增强之一是编译查询的自动使用——在这种查询中,LINQ 到实体表达式树已被转换为纯 SQL。为了实现这一点,在第一次调用 EF5 时,框架配置查询所需的所有组件,缓存查询的某些组件,并将它们本地存储在内存中,以便任何后续调用都不需要翻译或加载资源。这大大提高了预热应用的性能。
本地数据库支持
现在在 EF5 代码优先开发模型中支持 LocalDB。事实上,它是 Visual Studio 2012 中使用的默认服务器。
如果你不熟悉 LocalDB,它是面向开发人员的新版 SQL Server。它旨在介于 SQL Server Express 和 SQL Server Compact 之间。你可能会问自己,“这不就是 SQL Server Express 的用途吗?”尽管微软的初衷是让 SQL Server Express 成为开发人员的数据库标准,但随着 SQL Server 系列的改进,维护开发人员的低开销、小占用空间的要求变得更加困难,因为其完全许可的兄弟公司对 SQL Server Express 提出了要求。为了纠正这种情况,微软创建了本地数据库,这是一个与快速版和其他版本的 SQL Server 相同的 SQL Server 可执行文件的重新打包,但没有 SQL Server 系列的大量占用和配置。重新打包允许我们作为开发人员在开发中维护 SQL Server 兼容性,而不必安装和配置 SQL Server Express 或其中一个企业变体。
LocalDB 与 SQL Server Compact 的不同之处在于,它是一个独立的进程(SQL Server Compact 是一个 in-proc DLL),并在 SQL Server Compact 不支持的情况下为存储过程和扩展数据类型提供支持。
枚举支持
EF5 终于获得了对枚举的支持,这是实体框架最早版本就要求的特性。框架对枚举的支持是通过将枚举值映射到数据库中的整数值和从数据库中的整数值映射来提供的。如果您需要一个查找表,想要在不重新编译的情况下添加或更改值,或者是不希望在其数据库中使用神奇整数的人,您仍然需要使用映射到查找表的类来表示枚举值。
关于枚举支持有一个重要的警告;仅当您的目标是的 4.5 版时,它才可用.NET 框架。如果您的目标是 4.0 版本,您将收到一条错误消息,指出框架无法映射枚举属性。但是,如果您的目标是的 4.5 版或更高版本.NET Framework 中,枚举是我们作为开发人员喜欢的那些漂亮的小“它就是工作”特性之一。
酿酒模式
实体框架支持三种不同的数据建模方式:数据库优先、模型优先和代码优先。
- 当数据库已经存在并且支持从现有数据库模式派生模型时,使用数据库优先
- “模型优先”方法支持可视化地建模我们的数据,并根据模型生成数据库
- 代码优先允许我们从代码中定义的模型生成数据库模式
由于我们正在开发一个新的应用,数据库优先的数据建模方法并不真正适用。在 Visual Studio 2010 中引入的模型优先方法可以用于 BrewHow 应用,但是我们将选择更敏捷的代码优先方法。
代码优先的方法还允许我们使用迁移以(基本上)非破坏性的方式更新模式。这很重要,因为我们将调整模型,以更好地映射我们的领域,因为我们不断增强我们的应用。另外,它很酷,我们喜欢新的闪亮的玩具,对吗?因此,让我们启用 EF5,并开始在我们的应用中使用它。
建模数据
使用 EF5 的代码优先特性,类将决定用于建模我们的领域的数据库模式。我们将从三个班级开始:Recipe、Review和Style。这些类中的每一个都将被添加到我们的酿酒应用的Models文件夹中。
食谱
Recipe类包含所有关于我们食谱的信息。这是上一章中介绍的类的稍加修改的版本。
public class Recipe
{
public int RecipeId { get; set; }
public string Name { get; set; }
public Style Style { get; set; }
public float OriginalGravity { get; set; }
public float FinalGravity { get; set; }
public string GrainBill { get; set; }
public string Instructions { get; set; }
}
审核
Review类包含与配方评审相关的信息。
public class Review
{
public int ReviewId { get; set; }
public int Rating { get; set; }
public string Comment { get; set; }
}
风格
Style类将允许我们将一种风格与特定的啤酒配方联系起来。啤酒风格的例子有印度淡啤酒、牛奶/甜啤酒、比尔斯纳啤酒和波特啤酒。
public class Style
{
public int StyleId { get; set; }
public Category Category { get; set; }
public string Name { get; set; }
}
类别
Category类是一个枚举,将用于定义啤酒为淡啤酒或淡啤酒。我们使用枚举,因为这些值不会改变。
public enum Category
{
Ale,
Lager
}
酿酒厂的背景
我们现在有了代表我们模型的类。我们需要一种方法将这些类映射到持久存储。为此,我们将创建一个名为BrewHowContext的类来扩展DbContext类。
DbContext是存储库和工作单元模式的组合。它提供了将代码优先模型映射到数据库的粘合剂。它还提供了更改跟踪,允许您对上下文中的一个或多个实体进行多次编辑,并将它们批量提交给数据库。
然而DbContext并不神奇。它需要对模型中存在的实体有所了解,对它负责的实体有所了解。DbContext使用特殊的集合类将实体映射到数据库中的表。这个班是DbSet<T>。
DbSet<T>类是实体框架中的一个特殊类,用于表示一组类型化对象,您可以在其上执行【CRUD】(创建、检索、更新和删除)操作。这些操作可以使用 LINQ 来执行,因为DbSet<T>实现了IQueryable界面。
在我们应用的Models文件夹中创建BrewHowContext。您需要在BrewHowContext.cs的using声明中添加System.Data.EntityFramework。
public class BrewHowContext : DbContext
{
public DbSet<Recipe> Recipes { get; set; }
public DbSet<Review> Reviews { get; set; }
public DbSet<Style> Styles { get; set; }
}
注
不能直接构造DbSet<T>类。只有DbContext类可以创建DbSet<T>的新实例。
生成我们的数据库
为了生成我们的数据库,我们只需要运行我们的应用,但是如果我们现在尝试运行它,我们将会得到一个编译错误。出现错误是因为我们更改了Recipe类的定义。在Controllers文件夹中找到RecipeController类,并用以下内容替换Index动作方法:
public ActionResult Index()
{
List<Recipe> recipes = null;
using (var context = new BrewHowContext())
{
recipes = (from recipe in context.Recipes
select recipe).ToList();
}
return View(recipes);
}
我们的应用现在应该编译并运行了。按 Ctrl + F5 启动我们的应用。

当然,没什么好看的。我们的表格标题在那里,但没有太多其他的。我们真正做的是创建数据库——一个空的数据库,但仍然是一个数据库。
要查看我们的数据库和其中的表格,请单击解决方案资源管理器工具栏上的查看所有文件图标。

展开App_Data文件夹,双击名为BrewHow.Models.BrewHowContext.mdf的文件。

我们新的数据库现在在数据库浏览器窗口中打开。

当您检查每个表的结构时,您会注意到实体框架足够聪明来识别表的键。这是一个超越常规配置的特性。以Id结尾且与包含它的类同名的类型为int或Guid的任何属性都将用作表的主键。
改变模型
当我们继续检查我们的新数据库时,有些事情我们需要改变。
目前,Review类与Recipe类没有关联。这似乎是我们的一个重大疏忽。我们需要提供一种从Recipe类导航到Review类的方法。
Style和Recipe的关系也需要修改。我们可以通过创建类型ICollection<T>的属性来表示Style和Recipe之间以及Recipe和Review之间的一对多关系。
至于约定,在Recipe类中有一个Style_StyleId的外键似乎有点多余。实体框架中的约定是在格式[EntityName][EntityKeyName]中寻找外键属性。在这种情况下,它正在寻找一个名为StyleStyleId的物业。由于该属性不存在,它创建了一个名为Style_StyleId的外键,以满足由Recipe类的Style属性标识的外键关系。
添加关系
以下代码显示了修改后的Review和Recipe类:
public class Recipe
{
public int RecipeId { get; set; }
public string Name { get; set; }
public Style Style { get; set; }
public decimal OriginalGravity { get; set; }
public decimal FinalGravity { get; set; }
public string GrainBill { get; set; }
public string Instructions { get; set; }
public virtual ICollection<Review> Reviews { get; set; }
}
public class Style
{
public int StyleId { get; set; }
public Category Category { get; set; }
public string Name { get; set; }
public virtual ICollection<Recipe> Recipes { get; set; }
}
超越常规
要覆盖约定,我们可以覆盖BrewHowContext类中DbContext的OnModelCreating方法。实体框架将在创建第一个BrewHowContext实例时调用该方法,并将其传递给DbModelBuilder实例。
DbModelBuilder为我们提供了支持,可以将模型的实体流畅地映射到它们所保存的数据库中。借助DbModelBuilder我们可以更改属性的列名或关系使用的键。我们甚至可以更改实体映射到的表的名称。
以下是修改Recipe到Review关系和Style到Recipe关系的模型映射的代码。
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
// Add a foreign key to Recipe from Review
// to account for the new relationship.
modelBuilder.Entity<Recipe>()
.HasMany(r => r.Reviews)
.WithRequired()
.Map(m => m.MapKey("RecipeId"));
// Adjust the relationship between Style
// and Recipe to fix the key name.
modelBuilder.Entity<Recipe>()
.HasRequired(s => s.Style)
.WithMany(s => s.Recipes)
.Map(m => m.MapKey("StyleId"));
base.OnModelCreating(modelBuilder);
}
现在运行我们的应用会产生一条错误消息,告诉我们我们的上下文已经改变。这是因为我们对模型进行了更改。该模型现在不再与数据库中保存的模型匹配。

幸运的是,EF5 提供了一种机制来改变我们的数据库,以匹配我们调整后的模型。这种机制就是迁徙。
启用迁移
在 EF5 中使用迁移需要我们使用包管理器控制台。打开包管理器控制台,点击工具菜单,点击库包管理器,然后点击包管理器控制台。

在控制台中,输入Enable-Migrations –ContextTypeName BrewHow.Models.BrewHowContext并点击进入。

注
通常,您可以在包装管理器控制台中键入Enable-Migrations,而无需指定-ContextTypeName参数。然而,我们的应用基于Internet Application模板,它也创建了一个Context类。当一个项目中存在多个Context类时,必须指定要启用迁移的Context类的名称。
启用迁移会在解决方案中添加一个新的Migrations文件夹。该文件夹包含两个控制模式和配置的文件。

初始创建迁移
最初的迁徙被命名为InitialCreate。它是为我们生成的,因为实体框架的迁移需要一个基线来工作。在我们启用迁移之前,我们的数据库已经初始化,因此该框架对数据库的InitialCreate迁移进行了逆向工程。
public partial class InitialCreate : DbMigration
{
public override void Up()
{
CreateTable(
"dbo.Recipes",
c => new
{
RecipeId = c.Int(nullable: false, identity: true),
Name = c.String(),
OriginalGravity = c.Decimal(nullable: false, precision: 18, scale: 2),
FinalGravity = c.Decimal(nullable: false, precision: 18, scale: 2),
GrainBill = c.String(),
Instructions = c.String(),
Style_StyleId = c.Int(),
})
.PrimaryKey(t => t.RecipeId)
.ForeignKey("dbo.Styles", t => t.Style_StyleId)
.Index(t => t.Style_StyleId);
CreateTable(
"dbo.Styles",
c => new
{
StyleId = c.Int(nullable: false, identity: true),
Category = c.Int(nullable: false),
Name = c.String(),
})
.PrimaryKey(t => t.StyleId);
CreateTable(
"dbo.Reviews",
c => new
{
ReviewId = c.Int(nullable: false, identity: true),
Rating = c.Int(nullable: false),
Comment = c.String(),
})
.PrimaryKey(t => t.ReviewId);
}
public override void Down()
{
DropIndex("dbo.Recipes", new[] { "Style_StyleId" });
DropForeignKey("dbo.Recipes", "Style_StyleId", "dbo.Styles");
DropTable("dbo.Reviews");
DropTable("dbo.Styles");
DropTable("dbo.Recipes");
}
}
通过检查InitialCreate类中的代码,我们可以看到生成的代码有两种方法:Up和Down。每种方法都使用流畅的应用编程接口。实体框架使用Up方法对数据库进行迁移。Down方法用于撤消迁移。
配置类
Configuration类为我们提供了一种控制迁移行为的方法。
internal sealed class Configuration : DbMigrationsConfiguration<BrewHow.Models.BrewHowContext>
{
public Configuration()
{
AutomaticMigrationsEnabled = false;
}
protected override void Seed(BrewHow.Models.BrewHowContext context)
{
/* … */
}
}
通过这个类,我们可以配置一些东西,比如通过迁移启用自动数据丢失,或者命令在超时前可以执行多长时间。您在代码中看到的两种用法是这个类最常见的两种用法:自动迁移支持和种子数据。
添加种子数据
Configuration类的Seed方法用于在迁移过程中插入样本数据。因为我们希望在启动应用时有一点可用的数据,所以让我们用以下代码替换Seed方法(您需要将BrewHow.Models添加到using语句中)。
protected override void Seed(BrewHow.Models.BrewHowContext context)
{
var brownAle = new Style
{
Name = "Brown Ale",
Category = Models.Category.Ale
};
var milkStout = new Style
{
Name = "Sweet/Milk Stout",
Category = Models.Category.Ale
};
var heffeweisen = new Style
{
Name = "Heffeweisen",
Category = Models.Category.Ale
};
context.Styles.AddOrUpdate (
style => style.Name,
brownAle,
milkStout,
heffeweisen
);
context.Recipes.AddOrUpdate(
recipe => recipe.Name,
new Recipe
{
Name = "Sweaty Brown Ale",
Style = brownAle,
OriginalGravity = 1.05M, FinalGravity = 1.01M
},
new Recipe
{
Name = "Festive Milk Stout",
Style = milkStout,
OriginalGravity = 1.058M,
FinalGravity = 1.015M
},
new Recipe
{
Name = "Andy's Heffy",
Style = heffeweisen,
OriginalGravity = 1.045M,
FinalGravity = 1.012M
}
);
}
这段代码向我们的BrewHowContext类DbSet添加了新的实体。这些实体将在执行Seed方法后交付存储。
非常重要的是要注意,每次我们应用迁移时都会执行这个方法。这意味着我们必须保持Seed方法与我们对模型所做的任何更改同步。
添加迁移
我们快到了。我们需要添加一个新的迁移,以反映我们所做的更改。我们使用包管理器控制台中的Add-Migration命令来完成此操作。
Add-Migration将用一个参数调用:迁移的名称。因为我们做的最大的改变是从Review到Recipe的外键引用,我们称之为Reviews。在包装管理器控制台中,输入Add-Migration Reviews并点击进入。

在我们项目的Migrations文件夹中打开新文件。您会发现一个名为Reviews的新迁移类。
public partial class Reviews : DbMigration
{
public override void Up()
{
/* Fluent Migrations Up */
}
public override void Down()
{
/* Fluent Migrations Down */
}
}
我们再次看到,我们支持通过Up方法从之前的模型迁移到这个模型,或者我们可以通过Down方法从这个模型迁移到之前的模型。
从数据库中为我们创建的Reviews迁移和InitialCreate迁移之间有一个关键的区别:Reviews迁移没有被应用。这允许我们在应用迁移之前对其进行一些最后的调整。让我们去掉外键的级联删除。更新两个AddForeignKey方法调用,将命名参数cascadeDelete设置为假并保存文件。
AddForeignKey(
"dbo.Recipes",
"StyleId",
"dbo.Styles",
"StyleId",
cascadeDelete: false);
AddForeignKey(
"dbo.Reviews",
"RecipeId",
"dbo.Recipes",
"RecipeId",
cascadeDelete: false);
剩下的就是我们应用迁移。
应用迁移
应用迁移实际上只是将迁移的Up或Down方法应用到数据库。在包管理器控制台中,输入Update-Database并点击进入。

当在没有参数的情况下执行Update-Database命令时,将对任何未应用的迁移应用Up方法。
类型
获取迁移命令的帮助
所有迁移命令都有帮助。只需在包管理器控制台中输入help然后输入需要帮助的命令,点击进入(例如help Update-Database)。花一些时间研究这些命令,因为您可以使用它们来生成 SQL 脚本、定位特定的迁移以及许多其他有用的东西。
要查看我们工作的输出,在数据库浏览器中打开 BrewHow 数据库。

我们可以看到我们在OnModelCreating中创建的映射已经成功应用。我们成功地将外键重命名为StyleId,Reviews表现在有了一个引用Recipes表的名为RecipeId的外键。
我们可以在数据库中查找,以确保我们的Seed方法按照我们认为的方式执行。要查看我们的Recipes表格的内容,右键单击Recipes表格,并从上下文菜单中选择显示表格数据。

我们的样本数据确实在那里。

消费模型
当我们在本章开始修改我们的RecipeController类时,我们替换了Index动作方法中的代码,以使用新的Recipe模型和BrewHowContext。如果您按下 Ctrl + F5 启动该应用,您将看到我们确实正在通过BrewHowContext使用数据库中的数据。

这令人印象深刻,但是我们的实现存在一些问题。首先,记下食谱的顺序。一个都没有。数据似乎是按照它被插入数据库的顺序。现在或多或少是这样,但这有三个很大的问题:
- 我们没有提供项目插入列表的日期。
- 查看按输入日期排序的食谱列表(最早的优先)对用户没有什么价值。
- SQL Server 可以并且将会更改数据的返回顺序。这几乎可以保证。
还需要注意的是,主页每次都会加载并显示数据库中的每个配方。这对小数据集和少量用户的影响微乎其微,但如果我们的数据库中有数百或数千个食谱和数百个用户——让我们谦虚一点——我们的应用性能将会受到影响。
这些问题的解决方案非常简单。我们可以从为菜谱提供默认的排序顺序开始。如果我们按照菜谱的名称进行订购,用户应该能够相当容易地找到他们想要的东西——至少在我们的数据库中只有几个菜谱的情况下。我们可以把改变排序顺序作为以后要解决的问题。
至于把主页上的食谱分成易于管理的小块,我们需要为分页提供支持。
分页
允许用户浏览食谱是一种屡试不爽的方法,可以将数据检索和展示分解成可消化的部分。
类型
分页选项
当用户接近页面底部时,可以使用永久滚动等技术来加载下一页数据。其他选项包括一个按钮,允许用户检索下一页数据并将其附加到当前视图。然而,传呼机是一个久经考验的选择,仍然被大多数网络用户所采用。随意实验。
为了支持分页,我们需要一个可以向视图返回分页结果的类。此类不仅需要提供页面的项目列表(在本例中是菜谱),还必须提供当前页面和支持分页控件的总页数。以下是PagedResult类:
public class PagedResult<T> : List<T>, IPagedResult
{
private const int PageSize = 10;
public PagedResult(IQueryable<T> query, int page)
{
this.Page = page;
this.TotalPages = (int) Math.Ceiling(
query.Count() / (double)PageSize);
this.AddRange(query
.Skip(page * PageSize)
.Take(PageSize));
}
public int Page { get; private set; }
public int TotalPages { get; private set; }
}
PagedResult类扩展了通用列表类,为视图提供食谱列表或任何其他内容。类本身也实现了一个名为IPagedResult的接口。
IPagedResult界面的存在允许我们在任何页面上创建一个通用的分页控件。界面本身非常简单,只包含Page和TotalPages属性。
public interface IPagedResult
{
int Page { get; }
int TotalPages { get; }
}
传递给实现IPagedResult的视图的任何模型都有提供分页信息所必需的信息。我们可以构建一个以IPagedResult的实现为模型的控件。我们的分页控件PagingPartial的代码如下所示:
@model BrewHow.Models.IPagedResult
<div id="pager">
@if (Model.Page > 0)
{
@Html.RouteLink("<< Prev", new
{
page = Model.Page - 1,
controller = ViewContext.RouteData.Values["controller"],
action = ViewContext.RouteData.Values["action"]
},
new
{
id = "paging-prev"
});
}
<span>Page @(Model.Page + 1) of @Model.TotalPages</span>
@if (Model.Page +1 != Model.TotalPages)
{
@Html.RouteLink("Next >>", new
{
page = Model.Page + 1,
controller = ViewContext.RouteData.Values["controller"],
action = ViewContext.RouteData.Values["action"]
},
new
{
id = "paging-next"
});
}
</div>
PagingPartial部分视图,如果第一页还没有,则显示查看上一页数据的链接,如果最后一页还没有,则显示查看下一页数据的链接,并显示当前页码和总页数。
为了构建到上一页和下一页的链接,控件使用RouteLink HTML 帮助器。RouteLink可以基于命名路由或任何路由值构建链路。我们将在后面的章节中详细讨论路由,但是请注意,我们正在传递用于构建路由的控制器和操作的值。这些都是我们在第 3 章、介绍 ASP.NET MVC 4的默认路由中讨论过的相同变量。
在RecipeController类的Index视图上加载PagingPartial控件很简单。
@{ Html.RenderPartial("PagingPartial", Model); }
剩下要做的就是修改Index动作,将页码作为参数,并返回一个PagedResult对象作为视图的模型。
public ActionResult Index(int page = 0)
{
PagedResult<Recipe> recipes = null;
using (var context = new BrewHowContext())
{
recipes = new PagedResult<Recipe>(
context.Recipes.OrderBy(r => r.Name),
page);
}
return View(recipes);
}
您会注意到,我们将我们的默认顺序,配方名称,应用于从BrewHowContext检索的配方。如果我们在PagedResult类中将页面大小设置为只包含一个配方,输出看起来类似于以下内容:

在发布你的作品之前,一定要把页面大小设置得合理一点。
总结
本章简要介绍了实体框架。向您展示了关于创建域模型和使用实体框架将该模型映射到数据库的信息。您还学习了如何更改模型的映射以及如何用样本数据播种数据库。
在接下来的章节中,当我们调整模型以支持新需求的实现时,我们将重新讨论 EF5。
在下一章中,我们将研究领域驱动设计,以及我们如何利用它的一些原则来帮助我们编写更易维护的代码,以及这些原则对我们如何构建代码的影响。然后,我们将修改我们的应用,以遵守这些原则。
五、酿酒领域和领域驱动设计
当我们开始讨论酿酒应用时,我们经历了一段相当长的关于啤酒和酿造过程的描述。虽然我承认我喜欢和任何愿意听的人谈论啤酒,偶尔也会和那些不愿意听的人谈论啤酒,但我还是提供了这些信息来帮助你理解与啤酒及其生产相关的概念。为什么知道如何酿造好啤酒对编写酿酒程序很重要?
我将用另一个问题来回答这个问题。如果有人让你写一个应用来确定一只空载燕子的风速,你能做到吗?如果你单干,你需要的不仅仅是软件开发技能。你需要很好地理解空气动力学和运动学。考虑到这种情况不太可能发生——亲爱的读者,如果是这样的话,你应该向领域专家咨询。
同样的原理也适用于设计软件;不仅仅是关于啤酒或燕子(非洲、欧洲或其他)的软件,而是任何领域的任何软件。如果你不了解这个领域,你就不能编写软件来支持它。
领域驱动设计(【DDD】)一句由 Eric Evans 首创的短语,是一种将领域及其抽象模型置于前沿的开发方法。在本章中,我们将学习 DDD 的基本概念和模式。然后,我们将把这些知识应用到我们的酿酒应用中。
DDD 的信条
对 DDD 的详细讨论可以,事实上也确实值得写一整本书。由于我们正在这里开发一个小应用,我们将重点关注与我们的开发工作相关的 DDD 部分。
领域模型
域模型顾名思义。这是一个领域的模型。不幸的是,这个术语也很模糊。如果有人谈论模型,大多数开发人员会立即想到应用的底层存储。这里不是这样。
简单来说,一个领域模型就是把问题空间翻译成一种通用语言。它可用于定义适用于实体、值、集合、属性、操作、事件或问题域中任何其他项目的通用术语。如果做得好,领域模型将没有技术指导或实现。领域模型提供的澄清使它成为一个优秀的工具,有助于跨组织所有级别的沟通。
实体
实体是领域模型的块,它们在领域内是唯一可区分的。它们的唯一性不是组成对象的属性的集合,而是它们在应用的整个生命周期中的持续存在。
在我们的应用中,配方是实体的一个例子。虽然配方的一个实例可能与许多其他配方共享相同的属性,但它在应用中是唯一可识别的,并且将贯穿其整个生命周期。
价值对象
价值对象是不能单独存在的对象。它们只存在于其他对象的上下文中。与实体不同,值对象不是唯一可识别的。值对象的另一个特征是它是不可变的——值对象的值不能改变。
重力是我们应用中价值对象的一个例子。虽然我们目前将原始重力和最终重力表示为浮点值,但我们可以将它们表示为更有意义的类型。重力类型将没有识别特征,并且在配方实体(值对象的定义)的上下文之外没有意义。
骨料
聚合是由单个父对象控制的对象的集合。你可以把它们想象成一个对象图或树。聚合的父级称为聚合根。对聚合中任何子对象的访问都是通过聚合根获得的。
当我们在第 4 章、中为 BrewHow 创建模型时,我们在 EF5 中构建了一个由Style、Recipe和Reviews集合组成的集合。在这个聚合中,Recipe类是聚合根。Reviews离不开Recipe,同样,如果没有与Style相关联的Recipes,Style在我们的领域内缺乏目标。
工厂
当聚合或实体变得过于复杂而无法简单构建时,工厂就被用来创建。它们提供了一种方法来封装对象所需的引导,并强制执行任何关于创建实体或聚合的规则。
工厂与基础设施没有联系,只处理实体或集合的创建。
储存库
与处理聚合和实体创建的工厂不同,存储库用于聚合和实体的持久化和检索。它们为存储库的客户端提供了一种通过实体或集合的标识特征来检索对其的引用的方法,或者为客户端提供了一种将新的集合或实体保存到存储中的方法。
存储库应该为客户端提供一个简单的界面,该界面不包含任何标识用于存储的底层基础架构的信息。换句话说,存储库的存在是为了将一个领域的实体从领域模型映射到数据模型。它们是领域模型及其持久性之间的边界。
服务
虽然 DDD 的大部分域逻辑存在于实体本身,但有时某些域规则或决策不一定属于某个对象。一个常见的例子是两个账户之间的资金转移。您会将转移这些资金的责任分配给哪个实体?
服务就是为了满足这种需求。它们封装了可以在多个聚合或实体之间共享的域逻辑。
类型
了解 DDD 更多信息
正如引言中所说,领域驱动设计本身就是一个主题。如果你想了解更多,这里有几个资源,但是我建议你从 InfoQ 的快速领域驱动设计开始,然后阅读的领域驱动设计和的 Eric Evan 的解决软件核心的复杂性或者的Jimmy nilson 的应用领域驱动设计和模式,在 C#和. NET 中有例子。
酿酒设计
自从埃里克·埃文斯对 DDD 的开创性工作以来,分布式拒绝服务中出现了许多对模式和实践的变化和改编。今天,有许多实现宣称自己是 DDD,就像有许多 DDD 实践者一样。在设计我们的应用时,我们将专注于遵守前面描述的租户。变体在创建时会被记录下来。
在本章中,所有与我们的领域相关的代码都将放在项目中的Domain文件夹中。Domain文件夹将包含每个代码分组的子文件夹。

酿造实体
在这一点上,酿酒领域模型中的实体几乎是数据模型的镜像。我们有分别名为CategoryEntity、RecipeEntity、ReviewEntity和StyleEntity的Category、Recipe、、Review、和Style数据模型的域实体。这些实体负责特定于其在域中角色的业务规则和逻辑。
酿酒仓库
我们当前的模型有三个我们需要担心的实体:配方、评论和风格。假设配方是聚合根,那么就可以为配方聚合创建一个单独的存储库。然而,我们将改变这种做法。
虽然聚合根的管理对于连接的系统(状态被维护的系统)或小型聚合的根很有效,但是在不引入糟糕的编码实践和增加维护成本的情况下,将完整的实现放入这样大小的书中是不可能的。为了简单起见,我们将采用 DDD 变体,在大型集合上分解领域模型。每个断点都将提供一个参考点,以继续导航域模型到相关实体。
举个例子,我可以用第二个请求从一个Style实体中StyleId检索该样式的所有Recipes。第二个请求是经过深思熟虑的,它消除了我们对属于特定的Style的Recipes的急装和懒装的管理的担心。当从Recipe实体的Recipe导航到Reviews时,同样的规则适用。
注
我们采用的变体在实现上类似于本书范围之外的其他两种 DDD 模式:有界上下文和命令查询责任隔离 ( CQRS ) 。如果你对这些模式感到好奇,我建议你看看埃里克·埃文斯(是的,又是他)在《有限语境》中的作品,以及格雷格·杨在《CQRS》中的作品。
我们的领域模型可以分解如下:
- 一个
Style实体有多个Recipes和一个Category - 一个
Recipe实体有一个Style和多个Reviews - 一个
Review实体属于单个Recipe
基于这个细分,我们的应用需要三个存储库:StyleRepository、RecipeRepository和ReviewRepository。
下面的RecipeRepository类说明了如何管理域实体和数据模型之间的边界:
public class RecipeRepository : RepositoryBase
{
public IQueryable<RecipeEntity> GetRecipes()
{
/* ... */
}
public IQueryable<RecipeEntity>
GetRecipesByStyle(string styleName)
{
/* ... */
}
public RecipeEntity GetRecipe(int recipeId)
{
/* ... */
}
public void Save(RecipeEntity recipe)
{
/* ... */
}
// Consult the code accompanying this book for the
// full listing of this class.
}
在前几章中,我们直接在酿酒厂的模型上操作。通过放置一个存储库,我们可以直接对域的实体进行操作。存储库负责将模型(通常称为概念验证对象 ( 普通旧 CLR 对象)发送到域实体和从域实体接收模型。
注
如果我们选择这样做,我们可以使用我们为数据模型创建的概念验证操作系统来维护这个边界。也就是说,如果我们使用概念验证操作系统作为我们的域实体,我们将引入一种反模式,称为 aenemic 域模型,并增加无意中将数据持久性信息泄露到我们应用其他部分的可能性。
存储库对概念验证对象的封送是纯分布式拒绝服务的另一种变体。典型地,这是工厂的领域,但是我们的实体在这一点上构造起来相当简单,并且将工厂引入我们的应用可能有点多余。
消耗域
从存储库,我们现在可以在我们的控制器中使用域模型的实体和它们的封装逻辑。
在前一章中,我们将概念验证从BrewHowContext直接返回视图。由于已经在存储库中抽象出了BrewHowContext类,我们需要改变我们的RecipeController来使用RecipeRepository来检索和持久化配方实体。我们还希望将这些配方实体映射到视图本身可以使用的类。这将防止我们无意中允许对视图中的域实体执行代码。这个视图特定的类称为视图模型。
配方视图模型
设计用于显示食谱的视图的视图模型很简单。这实际上是RecipeEntity类到概念验证类的字段到字段的映射:
public class RecipeDisplayViewModel
{
[Key]
public int RecipeId { get; set; }
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Style")]
public string Style { get; set; }
[Display(Name = "Category")]
public string Category { get; set; }
[Display(Name = "Original Gravity")]
[DisplayFormat(DataFormatString = "{0:0.00##}")]
public float OriginalGravity { get; set; }
[Display(Name = "Final Gravity")]
[DisplayFormat(DataFormatString = "{0:0.00##}")]
public float FinalGravity { get; set; }
[Display(Name = "Grain Bill")]
[DataType(DataType.MultilineText)]
public string GrainBill { get; set; }
[Display(Name = "Instructions")]
[DataType(DataType.MultilineText)]
public string Instructions { get; set; }
[Display(Name = "ABV")]
[DisplayFormat(DataFormatString = "{0:0.00}")]
public float PercentAlcoholByVolume { get; set; }
}
您可能已经注意到视图模型类被大量的属性修饰。这些属性是在System.ComponentModel.DataAnnotations中找到的数据注释库的一部分,将在下一节中讨论。
你可能也注意到了班级的名字是RecipeDisplayViewModel。该类仅用于向消费者显示食谱。我们有一个单独的视图模型类,允许编辑或创建新的配方:
public class RecipeEditViewModel
{
[Key]
public int RecipeId { get; set; }
[Display(Name = "Style")]
public int StyleId { get; set; }
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Original Gravity")]
[DisplayFormat(DataFormatString = "{0:0.00##}")]
public float OriginalGravity { get; set; }
[Display(Name = "Final Gravity")]
[DisplayFormat(DataFormatString = "{0:0.00##}")]
public float FinalGravity { get; set; }
[Display(Name = "Grain Bill")]
[DataType(DataType.MultilineText)]
public string GrainBill { get; set; }
[Display(Name = "Instructions")]
[DataType(DataType.MultilineText)]
public string Instructions { get; set; }
public SelectList StyleList { get; set; }
}
视图模型的只读版本和读/写版本之间的差异是存在的,因为我们不想将衍生属性(如酒精体积百分比)显示为可修改的。同样,当显示只读信息时,我们不希望将模型数据返回到仅在尝试创建或编辑实体时有用的视图。
注
由于RecipeController的Index操作会将一个RecipeDisplayViewModels列表返回到视图中,IPagedResult已被稍微修改并移动到ViewModels文件夹中。如果您对这些变化感到好奇,请查阅本书附带的代码。
现在,让我们讨论应用于视图模型属性的所有属性。
数据标注
数据注释用于向运行库或属性化类和属性的使用者提供关于其行为的提示。这些提示可以帮助运行时验证分配给属性的信息,例如RangeAttribute数据注释。像我们的视图模型类中使用的DisplayAttribute这样的属性为用户界面提供了关于如何给特定属性添加标题的提示—Html.DisplayFor助手使用这个属性。
一个特别有用的属性是DataTypeAttribute。在第三章、介绍 ASP.NET MVC 4中,我们了解了显示模板和编辑器模板。如果某个属性存在,那么DisplayFor和EditorFor助手实际上将使用该属性的DataTypeAttribute。在我们的视图模型类中,我们使用DataTypeAttribute并指定GrainBill和Instructions属性应该被视为多行文本。现在,每当对这些属性调用DisplayFor时,运行时将在视图中呈现一个textarea控件,代替用于字符串值的标准文本input。
当然,对于要在视图中渲染为textarea元素的GrainBill和Instructions属性,我们需要首先为视图提供视图模型。
点击此处注册。
我们的控制器应该正在从存储库中检索实体并将它们返回到视图中。因为我们的存储库在实体上运行,而我们的视图在视图模型上运行,所以我们的控制器的工作之一就是执行这种转换。
需要修改RecipeController类来使用RecipeRepository方法,从存储库中检索并保存RecipeEntity对象,并提供这些实体和返回到视图的视图模型之间的映射。修改后的RecipeController类的一部分现在出现在代码中,如下所示:
public class RecipeController : Controller
{
private RecipeRepository _recipeRepository = new RecipeRepository();
public ActionResult Index(int page = 0)
{
var model =new PagedResult<RecipeEntity, RecipeDisplayViewModel>(_recipeRepository.GetRecipes(),page,ToDisplayModel);
return View(model);
}
public ActionResult Details(int id)
{
/* ... */
}
public ActionResult Create()
{
/* ... */
}
public ActionResult Create(RecipeEditViewModel recipe)
{
/* ... */
}
public ActionResult Edit(int id)
{
/* ... */
}
public ActionResult Edit(RecipeEditViewModel recipe)
{
/* ... */
}
private RecipeDisplayViewModel ToDisplayModel(RecipeEntity entity)
{
if (entity == null)
{
throw new ArgumentNullException("entity","Cannot convert null to a view model.");
}
return new RecipeDisplayViewModel
{
RecipeId = entity.RecipeId,
Name = entity.Name,
Category = entity.Style.Category.ToString(),
Style = entity.Style.Name,
OriginalGravity = entity.OriginalGravity,
FinalGravity = entity.FinalGravity,
PercentAlcoholByVolume = entity.PercentAlcoholByVolume,
GrainBill = entity.GrainBill,
Instructions = entity.Instructions,
};
}
// Consult the code accompanying this book
// for the full listing of this class.
}
您会注意到我们已经填写了一些附加操作。Details、Create和Edit操作允许用户分别检索特定配方的详细信息、创建新配方或编辑现有配方。其中两个动作Create和Edit重载了方法签名,每个签名都应用了不同的属性。
用[HttpPost]修饰的动作方法将只响应通过 HTTP POST 发生的请求。没有属性的操作方法隐式响应使用 HTTP GET 动词发出的 HTTP 请求。这些行动虽然名称相同,但有两个不同的目的。
GET 与 POST
未修饰的动作,即没有[HttpPost]的,对 HTTP GET 动词进行操作,并处理Create和Edit视图的呈现。它们只是提供一个界面,通过这个界面,我们的用户可以创建或编辑食谱。
响应 HTTP POST 动词的动作是用于创建或修改实体的动作。这些操作有一个参数,该参数对应于 RecipeEntity 视图模型的编辑版本。为了将运行时接收到的 HTTP POST 的内容转换成我们作为动作方法的参数使用的对象,控制器利用了 MVC 运行时的模型绑定基础结构。只有在响应 HTTP POST 时,才应该修改信息。
模型绑定
模型绑定是运行时将查询字符串或 POST 参数映射到动作方法属性的方式。当一个动作方法被调用时,模型绑定器检查传入请求的内容,并试图确定它是否需要将值映射到简单或复杂的属性——复杂的属性采用[parent].[property]的形式。然后,模型绑定器递归地开始构建模型,如果动作方法支持接收它们,那么模型将被传递回动作方法。
举例来说,假设一个视图包含一个表单来调用一些操作。表单本身包含一个名为MyProperty的input字段:
<input type="text" name="MyProperty" />
当用户单击提交时,运行时将查看正在调用的操作,并尝试将 POST 请求正文中的值封送到该操作的参数中。如果动作采用名为MyProperty的参数,模型绑定器将把提交时输入的任何值传递给动作参数MyProperty。如果动作采用复杂类型,并且复杂类型具有名为MyProperty的属性,则模型绑定器将构造复杂类型(如果可以的话),并将其MyProperty属性设置为从表单提交的值。
模型绑定是可扩展的,允许您为自定义类型创建新的绑定。如果它不能绑定到您的特定参数,您可以给它一点帮助。
配方视图
现在视图需要修改以接受一个视图模型,而不是数据模型的概念验证。为了创建这些视图,我们简单地利用 Visual Studio 提供的脚手架,我们在第 3 章、介绍 ASP.NET MVC 4中使用了这个脚手架。右键单击操作方法中的任意位置,调出我们的上下文菜单,然后单击添加视图...。

但是,这一次,我们将创建一个强类型视图,并选择视图模型作为我们的模型类。如果您正在跟进,请确保在添加视图对话框中选择合适的脚手架模板。下图显示了创建Edit视图的适当设置:

以下是我们Edit视图的代码。代码中突出显示的部分说明了我们是如何消耗我们的RecipeEditViewModel的StyleList财产的:
@model BrewHow.ViewModels.RecipeEditViewModel
@{
ViewBag.Title = "Edit";
}
<h2>Edit</h2>
@using (Html.BeginForm()) {
@Html.ValidationSummary(true)
<fieldset>
<legend>RecipeEditViewModel</legend>
<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Name)
@Html.ValidationMessageFor(model => model.Name)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.StyleId)
</div>
<div class="editor-field">
@Html.DropDownListFor(model => model.StyleId, Model.StyleList)
</div>
<!—See accompanying code for the full listing -->
<p>
<input type="submit" value="Save" />
</p>
</fieldset>
}
<div>
@Html.ActionLink("Back to List", "Index")
</div>
@section Scripts {
@Scripts.Render("~/bundles/jqueryval")
}
当我们调用视图时,运行时将Instruction和GrainBill属性的输入显示为多行输入,正如我们的DataTypeAttribute建议的那样。

总结
你现在对 DDD 有了基本的了解,我们已经将这种了解应用到我们的应用中。我们的应用现在加强了持久性、逻辑和显示之间的界限。我们已经识别并实现了与我们的领域模型相对应的实体。领域模型通过我们的存储库公开的实体被持久化和恢复。我们模型中的每个实体都可以包含特定于它的逻辑,如果需要,我们将实现服务来处理跨实体和工厂的交互,以构建复杂的实体。我们的控制器在我们的领域模型的实体和向用户展示的领域模型之间提供了粘合剂,如果用户选择的话,他们可以对其进行操作。
在下一章中,我们将构建我们的应用来使用控制反转和依赖注入。我们还将研究遵循 SOLID 原则的设计代码。利用这些模式将为我们提供一个更易维护的代码库。
六、编写可维护的代码
意大利面代码。大泥球。如果你已经写了至少几年的代码,你很可能知道这些术语。你肯定已经看到或者可能促成了一个如此不可维护的项目,以至于你打赌它会在自己的重压下崩溃。然而,如果你退后一步,深入挖掘这个项目的历史,你可能会发现它是从高尚的意图开始的。
没有人开始写坏代码。没有人开始编写不可维护的代码。无论是项目按时完成的紧迫性、无法解释地投入生产的概念验证代码,还是其他一些业务外力,有时我们最终只是编写了无法维护的代码。
在这一章中,我们将讨论如何设计我们的类和构建我们的应用,以使其更易维护,并通过代理,通过探索类设计的固体原则使其更易测试。如果坚持这些原则,并不能保证我们不会创建下一代开发人员会抱怨的项目。然而,它们确实让事情变得更加困难。一旦我们研究了这些原则,我们将把它们应用于酿酒。
固体原理
固体原则是五个面向对象的类设计原则的集合。罗伯特·马丁在 SOLID 首字母缩略词中加入的原则本身是首字母缩略词: SRP 、 OCP 、 LSP 、 ISP 和 DIP 。我们将在课堂设计中充分利用这些原则。
单一责任原则
单一责任原则 ( SRP ) 可以不严谨地翻译为“做一件事,把它做好。”如果你发现自己描述一个类或方法的功能时说它做了“A”和“B”,你很可能违反了这个原则。考虑我们的控制器。
有人可能会说我们的应用的RecipeController类违反了 SRP,因为它目前代表用户检索数据,将数据翻译成适合视图的格式,要么将请求的视图返回给用户,要么将它们重新路由到合适的视图。有很多工作要做。然而,如果我们声明我们应用的RecipeController类的意图是基于用户输入为视图检索数据,我们可能会丢失“和”,这听起来并不那么糟糕——毕竟,这是控制器应该做的。
也许更好的例子是考虑一个负责管理用户的类。这个假设的类只是创建、检索、修改和删除用户。
public class UserManager
{
public void CreateUser(User user) { /* … */ }
public void GetUser(UserId userId) { /* … */ }
public void UpdateUser(User user) { /* … */ }
public void DeleteUser(User user) { /* … */ }
}
用一个单一的类来管理用户将遵循 SRP。然而,如果有人决定用类的create方法构造电子邮件并发送给新用户,那么这个类现在就违反了 SRP。它现在负责维护用户并与他们交流。
因此,罗伯特·马丁更好地总结了这一点,他说,如果一个类改变的原因不止一个,它就违反了 SRP。
开启关闭原理
开放封闭原则 ( OCP ) 规定软件组件“应该开放扩展,关闭修改。”简单地说,如果一个类需要改变它的行为,那么这个行为应该通过面向对象的设计技术来改变,比如继承或者合成。
假设您有一个名为Transfer的方法,该方法旨在两个独立账户之间转移资金。写这门课的时候,有关于在任何一天可以从一个账户转账多少钱的规定,金额取决于账户类型。
public void Transfer(Account fromAccount, Account toAccount)
{
switch (fromAccount.Type)
{
case AccountType.Checking:
if (fromAccount.DailyAmountTransferred < 500)
{
// Transfer funds.
}
break;
case AccountType.Savings:
if (fromAccount.DailyAmountTransferred < 300)
{
// Transfer funds.
}
}
// Other logic.
}
如果增加MoneyMarket的新账户类型,或者简单地说,改变每日限额,会发生什么?随着代码的编写,这些变化中的任何一个都需要修改Transfer方法来支持扩展。如果Transfer方法不知道账户的类型和限额就更好了。
要让Transfer方法无知,我们需要让我们的Account类更聪明一点。如果我们将属性添加到Account类中,以向Transfer方法提供从账户转出的每日金额以及转账限额,Transfer方法可以支持现在或将来存在的任何账户类型。
public void Transfer(Account fromAccount, Account toAccount)
{
if (fromAccount.DailyAmountTransferred <
fromAccount.DailyAmountAllowed)
{
// Transfer funds.
}
}
这段代码将更易于维护,并且符合 OCP 的要求。转移对扩展开放,但对修改关闭。
注
严格遵守 OCP 通常意味着源代码和二进制输出都不允许修改。虽然这对于较大的企业应用来说是实用和理想的,但出于我们的目的,我们将仅将其应用于源代码。
利斯科夫替代原理
利斯科夫替换原则 ( LSP ) 规定,如果两个对象 A 和 B 是对象 Z 的子类型,那么任何对类型 Z 的对象进行操作的方法或类都可以对类型 A 或类型 B 的对象进行操作,而不改变应用的行为。
假设类Train和Motorcycle都是类Vehicle的子类型。让我们假设我们还有一个名为Navigate的类,它对传递给它的Vehicle类进行操作。如果Navigate类的一个实例被发送一个类型为Train的实例,并且它指示类型为Train的实例向左转,会发生什么?这当然会改变应用的行为,我无法想象它会在火车上有好的结局。
界面分离原理
你有没有发现自己实现了一个interface或者abstract类,并且对大多数定义的方法抛出了NotImplementedException异常?很有可能你实现的接口违反了接口隔离原则 ( ISP )。
ISP 规定接口——在这种情况下,任何实现都应该遵守的契约——应该小而具体。你可能还记得在第 5 章中对 CQRS 的简短提及【酿酒领域和领域驱动设计】。在 CQRS,针对数据存储的命令与旨在从同一数据存储中检索数据的查询是分开的。在 CQRS,由我们的存储库实现两个接口是完全合理和理想的:一个用于向数据存储提交数据,另一个用于检索数据。
如果你想要一个很多人认为明显违反这一原则的具体例子,你可能想看看老的 ASP.NET 成员资格 API。
public abstract class MembershipProvider : ProviderBase
{
public abstract bool ChangePassword(/* ... */)
public abstract bool
ChangePasswordQuestionAndAnswer(/* ... */)
public abstract MembershipUser CreateUser(/* ... */)
public abstract bool DeleteUser(/* ... */)
public abstract MembershipUserCollection
FindUsersByEmail(/* ... */)
public abstract MembershipUserCollection
FindUsersByName(/* ... */)
public abstract MembershipUserCollection
GetAllUsers(/* ... */)
public abstract int GetNumberOfUsersOnline();
public abstract string GetPassword(/* ... */)
public abstract MembershipUser GetUser(/* ... */)
public abstract MembershipUser GetUser(/* ... */)
public abstract string GetUserNameByEmail(/* ... */)
public abstract string ResetPassword(/* ... */)
public abstract bool UnlockUser(/* ... */)
public abstract void UpdateUser(/* ... */)
public abstract bool ValidateUser(/* ... */)
public abstract string ApplicationName { get; set; }
public abstract bool EnablePasswordReset { get; }
public abstract bool EnablePasswordRetrieval { get; }
public abstract int MaxInvalidPasswordAttempts { get; }
public abstract int MinRequiredNonAlphanumericCharacters
{ get; }
public abstract int MinRequiredPasswordLength { get; }
public abstract int PasswordAttemptWindow { get; }
public abstract MembershipPasswordFormat PasswordFormat
{ get; }
public abstract string PasswordStrengthRegularExpression
{ get; }
public abstract bool RequiresQuestionAndAnswer { get; }
public abstract bool RequiresUniqueEmail { get; }
}
在试图描述这份合同时,谈论什么不包括在内与什么包括在内可能更简单。
依存倒置原则
依赖反转原理 ( DIP )的形式定义假定应用中的高级逻辑不应该依赖于低级逻辑的特定实现。相反,高级逻辑在处理低级逻辑时应该只依赖抽象,反之亦然。
定义 DIP 的一个简单方法是说,您应该总是对接口进行编码,而不应该对接口的具体实现进行编码。
假设我们正在编写一个新闻阅读器应用,提供来自“乔的家庭新闻”的文章。我们可能在应用中有一个类似如下的方法:
public Article[] GetArticles()
{
JoesHouseONewsService newsService = new JoesHouseONewsService();
/* random service logic to filter the articles */
return newsService.Articles;
}
这段代码没有什么明显的错误,除了我们假设乔的众议院新闻将在两周后开始运作。经过深思熟虑后,我们意识到机会对可怜的乔不利,于是决定改变我们的应用,从 Feedly 阅读列表中为我们的用户提供文章。这就需要我们破解打开reader类,将其改为从新的来源拉文章。
如果我们在我们的GetArticles方法中编码到一个interface而不是一个特定的新闻服务,我们就没有必要修改这个方法。当然,你不能实例化一个接口,所以我们也必须引入一个Factory,正如我们在第 5 章中所讨论的,酿酒领域和领域驱动设计:
public Article[] GetArticles()
{
INewsService newsService = NewsServiceFactory.Create();
/* Same random service logic. */
return newsService.Articles;
}
每当我们对我们的新闻提供者进行更改时,我们的方法不再需要更改。我们甚至可以将该应用扩展到现在,支持多种来源的新闻消费。
凝固啤酒
让我们将我们现在所知道的关于固体类设计的知识应用到我们的应用中。鉴于抽象在 SOLID 设计中很重要,我们将从为我们的存储库添加抽象开始。
添加接口
英寸 NET 中,我们通过一个interface创建一个抽象,并通过一个class提供一个抽象的具体实现,所以我们的第一步应该是为我们的类提供接口。
打开 Visual Studio,在我们项目的Repositories文件夹中添加新的文件,即IRecipeRepository、IStyleRepository、IReviewRepository,以保存应用中每个存储库的界面。IRecipeRepository提取的界面如下:
public interface IRecipeRepository
{
IQueryable<RecipeEntity> GetRecipes();
IQueryable<RecipeEntity>
GetRecipesByStyle(string styleName);
RecipeEntity GetRecipe(int recipeId);
void Save(RecipeEntity recipe);
}
这些接口的范围很窄,并且遵守 ISP。虽然它们对于与 SOLID design 的其他租户保持一致也至关重要,但它们没有任何内在价值。它们必须首先有一个类形式的具体实现。
下一步是让存储库类实现接口。打开存储库类,并将适当的interface添加到class定义中。Recipe知识库声明如下:
public class RecipeRepository : IRecipeRepository
Style存储库声明如下:
public class StyleRepository : IStyleRepository
我们的类实现和接口抽象现在已经分离,但是我们需要更进一步,将实现和抽象放在单独的名称空间中。我们这样做是为了简化我们对 DIP 的遵守,因为任何特定实现的包含现在都必须通过将实现包含在using语句中来考虑。
抽象仍然是领域的一部分,但是这些领域级抽象的特定于实现的细节是我们的基础设施的一部分。
类型
企业发展
如果我们正在开发一个企业级的应用,我们不仅会将实现分成一个新的名称空间,还会分成一个新的程序集。这将允许我们在应用级别交换特定于技术的接口实现。我们将通过 IoC 容器来实现这一点,我们稍后会讨论。
基础设施
基础设施是指实际的类或特定的实现,应用和只有应用知道这些实现。我们需要在我们的项目中创建一个新的文件夹结构,我们将在其中放置我们的域级存储库的实现。
在 Visual Studio 中,在项目内创建一个名为Infrastructure的新文件夹。在新的Infrastructure文件夹中,创建另一个名为Repositories的文件夹,并将存储库接口的实现移动到这个文件夹中。确保您将每个存储库实现的名称空间从BrewHow.Domain.Repositories更改为BrewHow.Infrastructure.Repositories。

当我们移动实现并且现在需要引用抽象时,我们的控制器需要被修改,使得存储库成员变量被键入接口而不是存储库类。这将允许我们完成依赖反转。
在本章前面提供的 DIP 的定义中,我们使用工厂进行依赖反转。我们实现依赖反转的另一种方法是将依赖注入到类中。这种将依赖注入类的方法被恰当地命名为 依赖注入 ( DI )。
依赖注入
依赖注入是一种模式,通过这种模式,一个类的依赖被提供给这个类,这个类不需要去识别它们。DI 最常见的形式是构造函数注入 ( CI )。 CI 要求一个类声明它依赖的所有依赖项作为构造函数的参数。这种技术确保了类的实例不能被创建,除非它所依赖的所有依赖项在创建时都是可用的。
在 ASP.NET MVC 世界中,CI 是有问题的,因为在标准实现中,控制器需要有一个无参数的构造器。为了避开这个约束,一个常见的解决方案是重载构造函数,以提供一个配置项的构造函数和一个无参数的构造函数,该构造函数将这些依赖关系注入重载,如下所示:
private IRecipeRepository _recipeRepository = null;
private IStyleRepository _styleRepository = null;
public RecipeController()
: this( new RecipeRepository()
, new StyleRepository())
{
}
public RecipeController(
IRecipeRepository recipeRepository,
IStyleRepository styleRepository)
{
this._recipeRepository = recipeRepository;
this._styleRepository = styleRepository;
}
我们现在已经修改了我们的控制器来依赖抽象,并颠倒了我们的依赖关系。如果我们需要改变存储库类的实现来使用不同的技术,或者更有可能的是,提供返回模拟数据进行测试的实现,我们不再需要改变RecipeController类中的代码。如果存储库接口的所有实现不改变存储库的预期行为,这些抽象有助于我们遵守 LSP。
我们已经取得了很多成就,但我们的设计仍然存在一些问题。首先,我们还没有解决我们的控制器知道在哪里寻找它们的依赖的基本问题。这意味着,如果我们更改存储库的默认实现,我们必须打开在其无参数构造函数中创建类实例的每个控制器,并更改被实例化的类的类型。我们需要一些其他的方法来解决这些依赖关系,为此,我们将利用一个被称为服务定位器的模式。
注
服务定位器通常被认为是反模式的,因为它给代码库带来了一些非常独特的问题。虽然没有具体说明,但出现问题的场景通常与框架的开发有关,而与应用无关。话虽如此,如果您决定在未来的开发工作中使用服务定位器模式,我建议您首先进行研究。
服务定位器
一个服务定位器正是顾名思义。它为代码提供了一种方法,可以根据请求定位特定抽象的实现。调用代码不知道抽象是如何实现的,也不知道抽象的依赖关系是什么。它只知道请求一个抽象。
定位器本身通常在应用级别配置。配置可以是用代码表示的流畅配置,也可以从外部配置文件加载配置。我们将要创建的服务定位器将使用托管可扩展性框架 ( MEF )来检查我们的程序集,定位特定的实现,并将它们导出以供服务定位器使用。
托管可扩展性框架
由于我们将使用 MEF,我们应该对此有所了解。MEF 旨在通过使用称为部分的组件为应用提供可扩展性——遵守契约的代码片段。这些零件在目录中注册。每个目录又被添加到一个或多个组合容器中,应用可以从这些容器中请求实现特定契约的部分。这些部分可以组合在一起形成其他部分,并且它们共享容器的生命周期——直到创建它的合成容器被处置,一个部分才被处置。
所有这些作品听起来都很熟悉。我们自己通过一系列依赖注入在我们的应用中组成类。我们当前的控制器负责处理注入其中的存储库。每个控制器及其存储库都是作为一个单一的单元存在的——一个由其他部分合成的部分。
随船运输.NET 框架 4.5 是 MEF 2.0。MEF 2.0 在初始版本的基础上提供了一些改进,包括复合和泛型类型支持的多个范围级别。然而,我们最感兴趣的是基于约定的配置能力。
注
微软在 2011 年开发了一个专门为在 MVC 中使用 MEF 而设计的 NuGet 包。然而,这个包是实验性的,在编写时只支持 MVC 中的对象组合。虽然这对于我们正在做的事情来说已经足够了,但 MEF 2.0 支持我们需要的一切,并附带.NET 4.5 框架。因此,它在这里被利用。
基于约定的配置
在 2.0 版本之前,MEF 要求代码修饰Import和Export属性。Import属性将定义特定代码段所需的依赖关系,而Export属性将定义特定代码段可以使用的零件类型。这种基于约定的配置集中在RegistrationBuilder类中,我们将围绕这个类构建我们的 MEF 服务定位器。
MEF 服务定位器
在的App_Start文件夹中,创建一个名为ServiceLocatorConfig的新类,并在该类中放置以下代码:
public class ServiceLocatorConfig
{
private static CompositionContainer _container = null ;
public static void RegisterTypes()
{
RegistrationBuilder rb = new RegistrationBuilder ();
rb
.ForTypesDerivedFrom<IRecipeRepository >()
.Export<IRecipeRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
rb
.ForTypesDerivedFrom<IStyleRepository>()
.Export<IStyleRepository >()
.SetCreationPolicy(CreationPolicy.NonShared);
ServiceLocatorConfig._container =
new CompositionContainer(
new AssemblyCatalog(
Assembly.GetExecutingAssembly(),
rb
)
);
}
public static CompositionContainer Container
{
get { return ServiceLocatorConfig._container; }
}
}
班级本身就相当稀疏。正如我所说的,我们在很大程度上利用了RegistrationBuilder类,正如你所看到的,我没有低估这一点。我们将继续利用这个类,让我们通过检查下面的代码来讨论它在做什么:
RegistrationBuilder rb = new RegistrationBuilder ();
rb
.ForTypesDerivedFrom<IRecipeRepository >()
.Export<IRecipeRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
首先,我们构建一个RegistrationBuilder类的实例。然后,我们告诉RegistrationBuilder实例我们想要导出用于消费的类的类型,以及我们是否想要共享这些类。
代码的高亮部分告诉注册生成器为从IRecipeRepository派生的所有类型导出一个类型IRecipeRepository。请注意,我们没有使用Export方法导出一种类型的RecipeRepository。这种区别很重要。如果我们将导出从Export<IRecipeRepository>()更改为简单的Export()或等效的Export<RecipeRepository>(),我们将导出实现接口的实际类:RecipeRepository。然后,我们将需要从定位器请求实际的实现,并且不会将我们的实现从抽象中分离出来。
除了为实现它的所有部分导出IRecipeRepository接口之外,我们通过将创建策略设置为NonShared来为每个请求创建一个新实例。
注
我们在本章开头简要讨论了用于声明依赖关系的Import和Export属性,以及实现这些依赖关系的能力。MEF 允许部分指定它们是否可以共享,以满足其他类的依赖声明。同样,组件可以声明它们的依赖关系必须是共享的或者对它们是唯一的。除非部件与预期类型和支持的创建策略都匹配,否则 MEF 不会提供对部件的依赖。MEF 支持NonShared、Shared和Any的创作政策。
然后我们需要构建一个CompositionContainer并告诉它我们的出口在哪里。
ServiceLocatorConfig._container =
new CompositionContainer(
new AssemblyCatalog(
Assembly.GetExecutingAssembly(),
rb
)
);
该代码创建一个新的CompositionContainer并将其分配给_container成员变量。CompositionContainer类是 MEF 中扩展ExportProvider类的几个类之一。这些类提供了一个接口,消费者可以通过该接口检索导出的类型。
为CompositionContainer类提供了一个ComposablePartCatalog的实例,在本例中是一个AssemblyCatalog的实例作为参数。当要求特定接口的实现时,CompositionContainer类将咨询任何注册的ComposablePartCatalogs以确定它们是否可以返回实现。目录本身知道搜索RegistrationBuilder任何允许出口的类型。我们的AssemblyCatalog将在当前执行的组件中搜索RegistrationBuilder中定义的类型,如果找到并声明要导出,则将导出它们。
注
MEF 支持从当前正在执行的程序集中导出类型。例如,您可以通过使用DirectoryCatalog而不是我们当前使用的AssemblyCatalog来导出目录中所有程序集的所有类型。
使用 MEF 服务定位器
现在我们已经创建了我们的服务定位器,我们需要连接它。我们首先将高亮显示的代码添加到我们的Global.asax.cs文件中的Application_Start方法中:
protected void Application_Start()
{
ServiceLocatorConfig.RegisterTypes();
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
AuthConfig.RegisterAuth();
}
最后,我们将调整控制器以使用新的服务定位器类。删除包含Recipe和Style存储库参数的Recipe控制器的构造函数,并用以下构造函数替换无参数构造函数:
public RecipeController()
{
this._recipeRepository = ServiceLocatorConfig
.Container
.GetExportedValue<IRecipeRepository>();
this._styleRepository = ServiceLocatorConfig
.Container
.GetExportedValue<IStyleRepository>();
}
新的RecipeController类现在不依赖于Infrastructure命名空间中的任何类。我们需要某种方式来进一步抽象这一点。理想情况下,我们希望返回到构造函数注入,这样我们的类就不能在没有解决所有依赖关系的情况下被构造,但是我们需要一种方法来克服控制器有一个无参数构造函数的要求。幸运的是,微软为我们提供了一种方法,即依赖解析器。
依赖关系解析器
ASP.NET MVC 框架通过System.Web.Mvc.DependencyResolver类为提供了一个依赖性解析的扩展点。这个类有一个静态方法SetResolver,接受System.Web.Mvc.IDependencyResolver的一个实例作为参数。
在内部,MVC 框架很大程度上利用了DependencyResolver类。我们特别感兴趣的是IControllerFactory和DefaultControllerFactory的默认实现 MVC 框架中负责实例化控制器的部分——使用IDependencyResolver的注册实现来尝试为特定控制器找到具体的实现。如果找到一个实现,DefaultControllerFactory将尝试使用IDependencyResolver的注册实现进一步满足控制器的依赖性。
这个功能允许我们在控制器中返回到我们首选的构造函数注入方法,前提是我们用IDependencyResolver的实现注册控制器类和控制器类所依赖的类,然后向框架提供解析器。
我们还可以更进一步,为我们的依赖关系定义依赖关系。例如,我们可以要求RecipeRepository已经向其中注入了一个实现某些IDbContext接口的类。这将允许我们在将实体转换成模型或从模型转换实体时,测试将在RecipeRepository中发生的逻辑,或者为部分集成测试的应用提供模拟数据。
mefd dependency Resolver 类
MefDependencyResolver类实现IDependencyResolver接口,实现两种方法GetService和GetServices。这些方法允许请求者找到已经向解析器注册的特定类型的单个服务或多个服务。
public class MefDependencyResolver : IDependencyResolver
{
private ExportProvider _parentContainer;
private const string RequestContainerKey = "ServiceLocatorConfig.RequestContainer";
public MefDependencyResolver(ExportProvider parentContainer)
{
this._parentContainer = parentContainer;
}
public object GetService(Type serviceType)
{
var export = this
.RequestContainer
.GetExports(serviceType, null, null)
.SingleOrDefault();
if (export != null)
{
return export.Value;
}
return null;
}
public IEnumerable<object> GetServices(Type serviceType)
{
var exports = this
.RequestContainer
.GetExports(serviceType, null, null);
foreach (var export in exports)
{
yield return export.Value;
}
}
public void Dispose()
{
using (RequestContainer as IDisposable) { }
}
ExportProvider RequestContainer
{
get
{
ExportProvider requestContainer =
HttpContext
.Current
.Items[RequestContainerKey] as ExportProvider;
if (requestContainer == null)
{
requestContainer =
new CompositionContainer(
this._parentContainer);
HttpContext
.Current
.Items[RequestContainerKey]
= requestContainer;
}
return requestContainer;
}
}
}
我们的MefDependencyResolver接收到一个ExportProvider作为构造函数参数,允许我们将我们的CompositionContainer类传递给构造函数,但不禁止我们在将来更改ExportProvider的类型。
当 MEF 被引入的时候,我们讨论了一个部件将如何生存,直到组成它的容器被处理掉。由于对容器的每个请求都会创建一个新的合成容器——我们的部分有一个NonShared创建策略——这可能会有问题,因为对我们的应用的每个请求都会创建一个控制器和两个存储库。为了解决这个问题,我们需要将组合容器的范围扩展到当前的 HTTP 请求。我们的依赖解析器通过RequestContainer属性来实现这一点。
当被问及某个类型的具体实现时,GetService和GetServices的实现只是将请求转发给RequestContainer属性。此属性查看当前的 HTTP 请求缓存,以查看容器的实例是否已经存在。如果没有,则创建一个新的CompositionContainer类作为全局组合容器的子级,并将其添加到缓存中。然后,GetService和GetServices方法查看RequestContainer属性的出口以找到请求的部分。
放入 HTTP 请求缓存的子容器能够定位和创建部件,因为它继承了分配给其父容器的整个目录集合。当它被分配给请求缓存时,我们可以在请求完成时处置子目录,以处置它创建的任何部分。
protected void Application_EndRequest()
{
using (DependencyResolver.Current as IDisposable) { }
}
完成转换
为了完成我们的到真正的 SOLID 代码库的转换,我们需要为我们的DbContext类提供抽象,在我们的类中声明依赖关系作为构造函数参数,向我们的解析器注册这些依赖关系,然后向框架注册我们的解析器。
IBrewHowContext
那么,我们的第一步是为我们目前正在扩展的DbContext类提供一个接口。以下是我们对IBrewHowContext界面的定义:
public interface IBrewHowContext
{
IDbSet<Recipe> Recipes { get; set; }
IDbSet<Review> Reviews { get; set; }
IDbSet<Style> Styles { get; set; }
int SaveChanges();
}
请注意,我们的界面已经将Recipes、Reviews和Styles属性的返回类型更改为IDbSet<T>集合,而不是DbSet<T>集合。因为DbSet类没有公开公共构造函数,如果我们选择向我们的存储库实现提供模拟数据,我们就不能返回DbSet集合。因此,我们不得不调整IDbSet的定义。
我们的BrewHowContext类的定义现在应该如下所示:
public class BrewHowContext : DbContext, IBrewHowContext
为了将实现从抽象中分离出来,我们现在需要将BrewHowContext类移动到我们项目中Infrastructure文件夹的Repositories文件夹中,并将命名空间调整为BrewHow.Infrastructure.Repositories。这一举动需要我们调整BrewHowContext的建造者。
public BrewHowContext()
: base("BrewHow.Models.BrewHowContext")
{
}
按照惯例,实体框架使用上下文的完全限定名作为它所连接的数据库的名称。当我们通过更改名称空间来更改完全限定名时,如果我们想保留现有的样本数据,那么这个构造函数的修改是必要的。
储存库
存储库现在应该被修改,以便通过构造函数注入向它们提供上下文。这个变化对代码库的影响更大一点。以前,每种方法都在using语句中构造一个BrewHowContext。由于上下文将被提供给构造器,我们将需要接触每个方法并移除BrewHowContext的创建。RecipeRepository的代码说明了区别。
public class RecipeRepository
: RepositoryBase, IRecipeRepository
{
private IBrewHowContext _context;
public RecipeRepository(IBrewHowContext context)
{
this._context = context;
}
/* Other methods omitted for space */
}
注册依赖关系
我们已经创建了一套全新的依赖关系。首先,我们已经恢复到将依赖注入控制器的构造函数中。这些依赖关系,即存储库,现在以IBrewHowContext的形式将它们的依赖关系提供给它们的构造器。我们需要向我们的MefDependencyResolver,使用的CompositionContainer类注册这些依赖项,然后向 MVC 运行时注册我们的解析器。
我们新的ServiceLocatorConfig类如下:
public class ServiceLocatorConfig
{
private static CompositionContainer _container = null;
public static void RegisterTypes()
{
RegistrationBuilder rb = new RegistrationBuilder();
RegisterDbContexts(rb);
RegisterRepositories(rb);
RegisterControllers(rb);
ServiceLocatorConfig._container = new CompositionContainer(
new AssemblyCatalog(
Assembly.GetExecutingAssembly(),
rb
)
);
var resolver = new MefDependencyResolver(ServiceLocatorConfig._container);
DependencyResolver.SetResolver(resolver);
}
private static void RegisterDbContexts(RegistrationBuilder rb)
{
rb.ForTypesDerivedFrom<IBrewHowContext>()
.Export<IBrewHowContext>()
.SetCreationPolicy(CreationPolicy.NonShared);
}
private static void RegisterRepositories(RegistrationBuilder rb)
{
rb.ForTypesDerivedFrom<IRecipeRepository>()
.Export<IRecipeRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
rb.ForTypesDerivedFrom<IStyleRepository>()
.Export<IStyleRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
}
private static void RegisterControllers(RegistrationBuilder rb)
{
rb.ForTypesDerivedFrom<Controller>()
.Export()
.SetCreationPolicy(CreationPolicy.NonShared);
}
}
注
静态方法,事实上,这个类的整个静态方法都是不必要的。它的位置与App_Start文件夹中的其他类一致。如果使用static关键字使您的眼睛抽搐,请随意将这些方法和类更改为基于实例的。
您可以看到我们的控制器、我们的存储库和我们的上下文现在都注册到了RegistrationBuilder实例。反过来,RegistrationBuilder实例通过AssemblyCatalog向CompositionContainer类提供出口。然后我们构建一个新的MefDependencyResolver,向构造器提供一个CompositionContainer的实例。最后,我们的MefDependencyResolver通过调用DependencyResolver类上的静态SetResolver方法注册到 ASP.NET MVC 运行时。
启动该应用,验证它仍然有效,宣布成功,然后去买一个自制程序。你学到了很多,是时候庆祝一下了。
总结
我们现在有了一个设计扎实的应用。我们的抽象的所有具体实现都是通过DependencyResolver在运行时提供给应用的,我们通过坚持类设计的 SOLID 原则在应用中获得的松散耦合现在使它更易于维护和测试。我们还学习了一些关于依赖注入和 MEF 的知识。
在下一章中,我们将考虑通过允许用户添加对我们的食谱的评论和按风格查看食谱来为我们的酿酒应用提供更多功能。这个新功能将要求我们查看 MVC 框架的路由机制和对区域的支持。
七、使用路由和区域分离功能
想想所有以facebook.com或twitter.com开头的网址。想想微软网站的 MSDN 部分。想象一下,如果您必须维护控制器和操作来处理从这些站点返回的每一条内容。可以非常肯定地说,这项任务是艰巨的,几乎是不可能的。
虽然我们非常幸运,能够在自己创造的东西中处理这些问题,但很可能我们永远也不会关心如此大规模的可伸缩性和功能。这并不意味着我们的应用永远不会超过某个维护门槛。当它发生时,我们可能会决定我们需要把它分成不同的逻辑部分。我们可能会选择这样做,因为我们的应用变得太大或太复杂,无法让每个控制器都位于Controllers文件夹中。有时候只是希望我们的应用的网址简单、有意义、有 RESTful。
在这一章中,我们将把我们的应用分成逻辑部分。这些划分,在架构上,将强制分离关注点。对于我们的用户来说,这些划分将产生有意义和可预测的网址。为了实现这一点,我们将利用 ASP.NET MVC 4 框架的两个特性:路由和区域。
路由
在第 3 章、介绍 ASP.NET MVC 4中,我们了解到 ASP.NET MVC 4 框架通过查看添加到我们路由表中的路由来决定哪个控制器应该处理传入请求。实际上,它使用路由表不仅仅是匹配传入路由。它还使用路由表来确定如何使用 HTML 助手ActionLink和RouteLink生成网址。当确定如何路由传入请求或生成链接时,运行时从路由表中选择第一个匹配的路由。
目前,我们的RouteConfig类注册了一个名为Default的单一路由:
routes.MapRoute(name: "Default",url: "{controller}/{action}/{id}",defaults: new {
controller = "Recipe",
action = "Index",
id = UrlParameter.Optional
}
);
这条路由对于大多数应用来说已经足够了,在这些应用中,用户只需创建对象,然后通过id检索它们。然而,我们的要求之一是允许我们的应用用户根据风格过滤食谱类型的能力。过滤后的食谱列表最好有一个/Recipe/{style}的网址,这对用户来说是有意义和清晰的。
由于我们的应用目前正在运行,如果我们使用/Recipe/{style}格式的网址调用我们的应用,Default路由将导致 404 错误,除非样式名称碰巧与控制器内的动作名称一致;即便如此,它也可能导致我们的应用抛出一个错误。我们需要做的是注册一个新的路由来支持/Recipe/{style}格式,并提供一个用户可以调用新路由的机制。
按样式定位
我们的默认路由是简单的三段路由,有两个默认值和一个可选值。我们的应用在 0 到 3 段之间的任何请求都将映射到此路由。假设此路由是路由表中注册的第一条路由,它将始终被选择用于具有 3 个或更少网段的任何 URL。我们要注册的新样式路由有两段,因此在默认路由之前注册我们的样式路由似乎是我们需要采取的解决方案。
诚然,在大多数情况下,在默认路由之前注册我们的Recipe/{style}路由可以解决问题,但这并不是万无一失的解决方案。考虑网址/Recipe/Details?id=3。它不是通常预期的格式,但它仍然是一个非常有效的网址。网址由 2 段组成,以Recipe开头。我们希望这个网址显示具有3的id的食谱的细节,但是如果我们在默认路由之前注册风格路由,运行时将尝试显示具有Details风格的所有食谱,这不是我们想要的。
我们需要的是能够先注册我们的风格路由,但只有在{style}段确实是一种风格的情况下才能选择它。为此,我们可以对路由应用约束。
路由约束
在路由上放置约束允许我们减少运行时在路由选择中涉及的模糊性。为了帮助我们,ASP.NET MVC 框架为我们提供了IRouteConstraint界面:
public interface IRouteConstraint
{
bool Match(
HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection);
}
当一条路由在运行时注册时,我们可以为路由表提供一组约束,每个约束实现IRouteConstraint接口。当评估路由匹配时,运行时不仅会尝试将请求与路由的网址进行匹配,还会确保请求通过了所有使用每个注册约束的Match方法设置的约束。
我们应该在样式路径上设置约束,以检查传入的样式是否存在。如果样式不存在,则该请求要么是针对由Default路由标识的动作,要么是无效的请求。
我们的RecipeStyleConstraint约束如下:
public bool Match(HttpContextBase httpContext,Route route,string parameterName,RouteValueDictionary values,RouteDirection routeDirection)
{
if (!values.ContainsKey(parameterName))
{
return false;
}
var styleRepository = DependencyResolver.Current.GetService(typeof(IStyleRepository))as IStyleRepository;
string styleName =(string)values[parameterName];
var style = styleRepository.GetStyles().FirstOrDefault(s =>s.Name == styleName);
return style != null;
}
下一步是在路由表中注册路由。我们的风格路由BeerByStyle应该限制{style}段以匹配我们新的RecipeStyleConstraint类中设置的规则:
routes.MapRoute(name: "BeerByStyle",url: "Recipe/{style}",defaults: new {
controller = "Recipe",
action = "Style"
},
constraints: new {
style = new RecipeStyleConstraint()
}
);
BeerByStyle路由在Default路由之前注册,确保先选中。它还确定任何映射到我们路由的请求都应该调用RecipeController上的Style操作。这是一种新的动作方法,重新使用了当前由Index方法使用的Index视图。Index视图被重用,因为Index和Style动作的内容没有什么不同。只有上下文发生了变化:
public ActionResult Style(string style, int page = 0)
{
var model = new PagedResult<RecipeEntity, RecipeDisplayViewModel>(
_recipeRepository.GetRecipesByStyleSlug(style),page,ToDisplayModel);
var styleEntity = _styleRepository.GetStyleBySlug(style);
if (style != null)
{
ViewBag.Title = styleEntity.Name + " Recipes";
}
return View("Index", model);
}
通过向ViewBag添加一个属性,在Style动作中解决上下文的这种变化。如果存在Index视图,则属性用作标题,否则Index视图会将标题设置为简单阅读Recipes:
@model BrewHow.ViewModels.ITypedPagedResult<BrewHow.ViewModels.RecipeDisplayViewModel>
@{
ViewBag.Title = ViewBag.Title ?? "Recipes";
}
<h2>@ViewBag.Title</h2>
通过样式来支持位置,剩下要做的就是为用户提供一种实际调用功能的方法。
风格互动
为了让用户使用我们创建的新功能,我们需要做一些事情。首先,我们需要修改RecipeController的Index视图,使样式名成为实际的链接。这将允许用户看到与他们可能喜欢的食谱风格相同的其他食谱。
我们还需要为用户提供一个直接的机制来找到一种风格,并从该风格中查看该风格的所有食谱。这将需要我们创建一个新的控制器和视图。
配方列表修改
食谱列表当前显示了该食谱的风格以及其他信息。我们需要修改配方列表,将样式的名称转换为实际的链接,这将调用我们的新路由。
到目前为止,我们对ActionLink HTML 帮助器的调用非常普通。我们为ActionLink助手提供链接文本和控制器动作。我们还将当前正在查看的实体的id以匿名类型的形式传递到路由数据字典中,如下所示:
@Html.ActionLink(item.Name, "Details", new { id=item.RecipeId })
为了调用我们的Recipe控制器的Style动作,我们需要将样式的Name属性传递给路由数据字典。为了将RecipeController's Style动作的路由与我们将要创建的样式控制器的路由区分开来,我们还希望将控制器的名称传递到 HTML 帮助器中。
用以下代码替换RecipeController类的Index视图中的Html.DisplayFor ( modelItem => item.Style)会将样式名称转换为链接:
@Html.ActionLink(
Html.DisplayFor(modelItem => item.Style).ToHtmlString(), "Style", "Recipe", new { style = item.Style }, null)
现在,请求将/Recipe/Heffeweisen适当映射到我们的Style行动。该操作过滤配方列表并将结果返回给用户。

样式控制器和视图
创建 StyleController类与我们创建的任何控制器没有什么不同。它是存在于我们项目的Controllers文件夹中的一个类,并且扩展了Controller类。
StyleController有一个单独的Index动作,从注入到构造函数中的IStyleRepository的实现中检索一系列StyleEntity对象。Index行动的实施在这一点上应该不是什么新鲜事:
public ActionResult Index(int page = 0)
{
var model = new PagedResult<StyleEntity, StyleDisplayViewModel>(
_styleRepository.GetStyles(),page,ToDisplayModel);
return View(model);
}
Index动作返回数据的视图也不是什么新鲜事。这是一个分页列表视图,向用户显示应用中的所有啤酒风格。列表中的每一行都显示了样式的名称、样式所属的类别以及查看与该样式相关联的所有食谱的链接:
@model IEnumerable<BrewHow.ViewModels.StyleDisplayViewModel>
@{
ViewBag.Title = "Styles";
}
<h2>@ViewBag.Title</h2>
<table>
<tr>
<th>
@Html
.DisplayNameFor(
model => model.Name)
</th>
<th>
@Html
.DisplayNameFor(
model => model.Category)
</th>
<th></th>
</tr>
@foreach (var item in Model) {
/* ... */
}
</table>
在我们对网站导航进行所有这些调整的同时,打开我们应用的布局文件,添加一个到Style控制器的链接,并删除【关于】的和联系我们的链接。还要更改页面底部的版权和顶部的徽标文本。带有导航调整的样式列表如下图所示:

我们的解决方案并非没有问题。例如,如果用户正在使用 Opera(或者这个例子中的模拟器),请求/Recipe/Brown Ale将用户重定向到谷歌。出现这种情况是因为 Opera 和一些移动浏览器(以及一些桌面浏览器)将包含空格的 URL 视为对搜索结果的请求。为了解决这个问题,我们需要稍微改变一下策略。我们希望我们的网址是人类可读的,但我们也希望它们能够唯一地识别它们所解析的内容。我们需要的是子弹。
重击酿酒
Slugs 是人类可读的标识符,可以单独使用,也可以与另一条可识别信息结合使用,以唯一地识别资源。蛞蝓不仅通过帮助用户唯一识别其浏览器当前显示的内容而对用户有益(/Recipe/Heffeweisen比/Recipe/1信息量大得多),蛞蝓在搜索引擎如何对待您的网站方面也发挥着巨大的作用,是常见的 搜索引擎优化 ( SEO )技术。
鉴于 slugs 可以解决我们现有的关于样式名称中空格的网址问题,并且它们可以为我们提供一些搜索引擎优化的好处,我们需要调整我们的模型、实体、视图模型和网址来支持 slugs。
模型蛞蝓
我们希望在我们的应用中增加对风格和配方实体、模型和视图模型的支持。实现这一点的第一步是修改Recipe和Style数据模型类以支持段塞流。这就像给每个模型添加一个Slug属性一样简单。而且,正如我们所了解的,对模型的修改将要求我们对数据库进行迁移。
暂存数据库
我们需要修改数据库以支持添加到Recipe和Style模型中的新Slug属性。我们可以通过打开包管理器控制台并执行添加-迁移 Slugs 来做到这一点。这将在项目的Migration文件夹中生成我们的 Slugs 迁移类,分别使用Up和Down方法添加和移除Slug属性。
当数据库被植入时,新属性将需要应用于它们的值。我们可以使用Configuration类的Seed方法来实现这一点,因为它将在应用这些新的更改时执行:
protected override void Seed(BrewHowContext context)
{
var brownAle = new Style
{
Name = "Brown Ale",
Category = Category.Ale,
Slug = "brown-ale"
};
/* ... */
context.Styles.AddOrUpdate(
style => style.Name,
brownAle,
/* ... */
);
context.Recipes.AddOrUpdate(
recipe => recipe.Name,
new Recipe
{
Name = "Sweaty Brown Ale",
Style = brownAle,
OriginalGravity = 1.05f,
FinalGravity = 1.01f,
Slug = "sweaty-brown-ale"
},
/* ... */
);
}
注
虽然我们能够使用配置类的Seed方法对数据进行这些调整,但有时这可能是不可能的,例如,如果您正在生产数据库上运行迁移。发生这种情况时,您可以使用DbMigration类的Sql方法直接对数据库执行 SQL,作为迁移过程的一部分。
将变更提交给模型所剩下的就是在包管理器控制台中运行Update-Database命令。
修改实体
域级别的实体需要进行调整,以支持对 slugs 的检索和设置,以及对属性施加的任何规则或逻辑。由于我们目前没有提供创建或编辑样式的机制,对StyleEntity类的调整就像在定义中添加Slug属性一样简单。给RecipeEntity类添加一个Slug属性有点不同。
一个网址,顾名思义就是一个统一资源定位符。网址永远唯一地标识一个资源在互联网上的位置。因为我们将使蛞蝓成为网址的一部分,我们的蛞蝓不能改变。其含义是RecipeEntity类的Slug属性被设置一次,而且只设置一次。因此,我们的RecipeEntity类需要逻辑放入Slug属性中,只允许Slug设置一次:
public string Slug
{
get
{
if (string.IsNullOrEmpty(this._slug))
{
if (string.IsNullOrEmpty(this.Name))
{
return string.Empty;
}
this._slug = Regex.Replace(
this
Name
ToLower()
.Trim(),
"[^a-z0-9-]",
"-");
}
return this._slug;
}
set
{
if (!string.IsNullOrEmpty(this._slug))
{
throw new InvalidOperationException(
"The slug for the recipe has already been set.");
}
this._slug = value;
}
}
我们的RecipeEntity类中的 slug 是在第一次检索时生成的,只有在不存在任何值的情况下才能赋值。如果生成,Name属性将用作Slug属性的种子值。任何其他设置都是无效操作。
类型
DDD 在行动
将我们的领域实体从我们的数据模型中分离出来,使我们能够独立于存储和表示来实施领域规则。从模型到实体的映射仍然发生在存储库中,但是存储库不负责知道应用于实体的规则。这只是将域转移到持久存储的一种方式。同样,我们的视图和控制器对域实体的规则一无所知。他们只是在存储库和用户之间封送它们。
蛞蝓检索
如果我们要通过它们的 slug 来检索样式,我们应该修改BrewByStyle路由来获取 slug,而不是样式的名称。我们需要修改仓库界面IStyleRepository来提供一个支持功能的方法。这个方法可以通过注入到RecipeController中的StyleRepository类来实现,并通过RecipeStyleConstraint中的依赖关系解析来使用:
public StyleEntity GetStyleBySlug(string slug)
{
return this.StyleEntities.FirstOrDefault(s => s.Slug == slug);
}
我们还需要对Default路由进行调整,以接受可选的废料。这里的废料对数据检索或存储没有影响。它只是用来让网址更清晰:
routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}/{slug}",
defaults: new {
controller = "Recipe",
action = "Index",
id = UrlParameter.Optional,
slug = UrlParameter.Optional
}
);
最后,我们的视图和视图模型也应该可以访问Slug属性。这里的工作相当直接。在视图模型上为配方和样式创建Slug属性,然后更改任何生成的链接,将废料传递回控制器。给生成的网址添加一个 slug 就像给ActionLink和RouteLink HTML 助手的routeValues参数添加一个 slug 键一样简单:
@Html.ActionLink(item.Name, "Details", new
{
id=item.RecipeId,
slug=item.Slug
})
通过阅读本书附带的代码,可以看到这些变化的全部范围。这些更改的结果可以在下面的截图中查看,该截图显示了网址中包含的废料配方的详细信息:

区域
虽然我们可以使用路由来创建友好且有意义的 URL,但是在分组功能方面,路由并不是最好的工具。当我们需要将功能分组到不同的逻辑容器中时,无论是减少控制器或动作方法的数量,还是创建一个位置来放置代码来处理横切关注点,区域都是您想要的。
虽然我们的应用专注于食谱的收集,但我们也希望为用户提供一个社交组件,允许他们查看他人提交的食谱。鉴于评审带来了他们自己的一套管理需求,我们有理由将这一功能划分到一个领域。
创建评论区
要将区域添加到我们的项目中,只需右键单击该项目,然后从上下文菜单导航到添加 | 区域……。

当出现添加区域对话框时,在标有区域名称:的文本框中输入Review,点击添加按钮。

MVC 项目中的区域都包含在Areas文件夹中。对于项目中包含的每个区域,都有一个文件夹结构,它在很大程度上反映了 MVC 项目根的结构。

我们的 Review区域包含一个名为ReviewAreaRegistration.cs的文件。该文件是一个区域注册文件,用于通知框架该区域的存在。
注册评论区
我们的Review区域中的ReviewAreaRegistration.cs文件扩展了一个名为AreaRegistration的特殊类。它包含一个向应用注册区域的方法和一个通过名称识别区域的属性:
public class ReviewAreaRegistration : AreaRegistration
{
public override string AreaName
{
get
{
return "Review";
}
}
public override void RegisterArea(AreaRegistrationContext context)
{
context.MapRoute(
"Review_default",
"Review/{controller}/{action}/{id}",
new {
action = "Index",
id = UrlParameter.Optional
});
}
}
通过调用AreaRegistration.RegisterAllAreas方法,应用被告知该区域和应用内的所有其他区域。我们在Global.asax.cs文件中这样做:
protected void Application_Start()
{
ServiceLocatorConfig.RegisterTypes();
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
AuthConfig.RegisterAuth();
}
注
你没疯。我们实际上并没有将这段代码放在这里,它是我们在创建 BrewHow 应用解决方案时选择的应用模板的一部分。除非您使用知道这些区域的模板,否则它们不会自动在应用中注册,然后它们会自动注册。
调用时,RegisterAllAreas方法在应用集中搜索所有继承AreaRegistration的类。然后它调用它找到的每个类的RegisterArea方法。在我们的RegisterArea方法中,我们注册运行时应该用来将传入请求映射到我们的Review区域中的控制器的任何路由。
如果您检查前面显示的我们的RegisterArea方法中的路由,您将看到所有根为Review/的请求都可以映射到我们的区域,并且随后的请求的任何部分都将映射到控制器、动作和标识中。继续添加一条蛞蝓作为评论区路由的一部分。如果您需要帮助,可以参考随附的代码。
配方审查控制器
我们的评论区需要一个控制器来处理列出和创建评论的请求。要添加控制器,只需右键单击Review区域的Controller文件夹,并添加名为RecipeController的控制器。
在添加控制器对话框中,确保选择清空 MVC 控制器模板,然后点击添加。一旦我们创建了视图模型,我们将返回来填写我们的RecipeController类。
配方审查视图模型
视图模型是我们的RecipeController动作方法和它们向其发送数据的视图的先决条件。右键单击Review区域文件夹,创建一个文件夹来保存我们的视图模型,并创建一个名为ViewModels的新文件夹。在这个文件夹中,创建两个名为ReviewEditViewModel和ReviewListViewModel的新类。这些类除了名字之外都是相同的。这些类的代码如下:
public class ReviewEditViewModel
{
public int Rating { get; set; }
public string Comment { get; set; }
}
public class ReviewListViewModel
{
public int Rating { get; set; }
public string Comment { get; set; }
}
配方审查行动方法
现在视图模型已经存在,我们可以填写我们的RecipeController类了。RecipeController类将有三个动作。
第一个动作Index包含检索由id参数识别的配方的所有评审的代码。
另外两种操作方法允许我们为一个食谱创建评论;用HttpGet修饰的动作方法返回用户进入评审的视图,而用HttpPost修饰的动作方法调用存储库来持久化评审,并将用户重定向回配方的细节:
public class RecipeController : Controller
{
public ActionResult Index(int id)
{
/* ... */
}
[HttpGet]
public ActionResult Create(int id)
{
/* ... */
}
[HttpPost]
public ActionResult Create(
int id,
ReviewEditViewModel reviewEditViewModel)
{
/* ... */
}
}
应该指出的是,我们的新RecipeController使用的是IReviewRepository的实例,但是没有实现该接口的类注册到我们的依赖解析器中。要解决这个问题,打开App_Start文件夹中的ServiceLocatorConfig.cs文件,并将IReviewRepository界面添加到导出中:
private static void RegisterRepositories(RegistrationBuilder rb)
{
rb.ForTypesDerivedFrom<IRecipeRepository>()
.Export<IRecipeRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
rb.ForTypesDerivedFrom<IStyleRepository>()
.Export<IStyleRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
rb.ForTypesDerivedFrom<IReviewRepository>()
.Export<IReviewRepository>()
.SetCreationPolicy(CreationPolicy.NonShared);
}
我们现在已经创建了我们的控制器,它运行的视图模型,以及允许我们创建和检索评论的操作方法。最后一步是创建我们的视图。
创建视图
我们将使用 Visual Studio 脚手架为我们的动作方法创建视图。我们首先需要构建我们的应用,让 Visual Studio 知道我们的视图模型。通过按下Ctrl+Shift+B或从构建菜单中选择构建解决方案来完成此操作。
构建完成后,右键单击Index操作中的任意位置,并从上下文菜单中选择添加视图。由于我们想要创建一个强类型视图来列出一个食谱的评论,所以勾选标记为的复选框创建一个强类型视图,将模型类设置为ReviewListViewModel,并在支架模板中选择列表。由于该视图将被放置在配方的详细信息下方,因此也要勾选标记为的复选框,创建为部分视图。完成这些步骤后,确认对话框看起来类似于下面的截图,然后点击添加。

选择 ReviewEditViewModel 作为模型类和为脚手架模板创建,对Create动作方法重复相同的步骤。确保此视图不是作为局部视图创建的。
在Review区域打开我们的RecipeController的Index.cshtml视图,并用以下代码替换:
@model IEnumerable<BrewHow.Areas.Review.ViewModels.ReviewListViewModel>
<table>
<tr>
<th>
@Html.DisplayNameFor(model => model.Rating)
</th>
<th>
@Html.DisplayNameFor(model => model.Comment)
</th>
</tr>
@foreach (var item in Model) {
<tr>
<td>
@Html.DisplayFor(modelItem => item.Rating)
</td>
<td>
@Html.DisplayFor(modelItem => item.Comment)
</td>
</tr>
}
</table>
<p>
@Html.ActionLink("Create New",
"Create",
new {
area = "Review",
id = ViewBag.RecipeId
}
)
</p>
Index视图现在列出了给定食谱的所有评论。它还提供了一种机制通过使用突出显示的ActionLink调用来创建新的审查。在ActionLink调用中,我们为路由数据字典提供了额外的值,因此当运行时构建 URL 时,它有足够的信息来创建到适当的Create动作方法的链接。
显示审核列表的最后一步是将其添加到我们根RecipeController中的详细视图中。打开Details.ascx文件,在底部增加以下一行:
@{Html.RenderAction("Index", "Recipe", new {
area = "Review",
id = Model.RecipeId,
slug = Model.Slug
});
}
区域路由值
请注意我们的RenderAction,就像在它之前突出显示的ActionLink代码一样,也在向路由数据字典传递一些额外的路由值。路由的区域通过路由数据字典传递,就像控制器和动作的值一样。在ActionLink和RenderAction链接中,运行时将在构建路由时使用当前请求的区域,除非另有指示。这在某些情况下会导致一些不必要的行为。
在这个特定的场景中,我们指示运行时使用Review区域为我们的配方控制器的视图呈现链接和动作。这将强制选择Review区域内的路由,而不是我们应用的任何其他区域。
我们的布局页面还包含链接,呈现与名为RecipeController的控制器相关联的视图。这些链接位于顶部导航横幅中:
@Html.ActionLink("Recipes", "Index", "Recipe")
当在我们应用的默认区域,这些链接呈现为/Recipes/Index。当在Review区域时,路由数据字典中的区域值为Review,前面的评估为/Review/Recipes/Index。这不是我们想要的。我们希望这些路由始终符合我们的食谱清单。如果我们将该区域提供给路由数据字典,我们可以解决这个问题:
@Html.ActionLink(
"Recipes",
"Index",
"Recipe",
new { area = "" },
null)
虽然说我们到此为止很好,但是运行时有不同的意见。如果我们现在启动我们的应用,我们仍然会看到一个不错的例外:

这个屏幕的概要是这样的:当确定作为请求的一部分执行的控制器时,运行时实际上并不评估整个名称空间,除非明确告诉这样做。因为我们的 app 包含两个名为RecipeController的控制器,所以运行时无法区分我们 app 根目录中的RecipeController和我们Review区域中的RecipeController。我们需要给它一个提示。
路由命名空间
当我们的路由在MapRoute方法中声明时,我们可以限制路由引擎搜索控制器的命名空间。我们目前在两个地方申报航线,我们的Review地区的ReviewAreaRegistration班和App_Start的RouteConfig班。
为了限制名称空间,我们只需要向ReviewAreaRegistration和RouteConfig添加一个namespaces参数。RouteConfig中新参数的值如下代码所示:
routes.MapRoute(
name: "BeerByStyle",
url: "Recipe/{style}",
defaults: new { controller = "Recipe", action = "Style" },
constraints: new { style = new RecipeStyleConstraint() },
namespaces: new [] { "BrewHow.Controllers" }
);
至于我们的ReviewAreaRegistration类,修改如下:
context.MapRoute(
name: "Review_default",
url: "Review/{controller}/{action}/{id}/{slug}",
defaults: new {
action = "Index",
id = UrlParameter.Optional,
slug=UrlParameter.Optional },
namespaces: new[] {
"BrewHow.Areas.Review.Controllers"}
);
现在,如果我们启动我们的应用,我们会收到我们熟悉的食谱列表。点击菜谱的详细信息,我们将看到一个屏幕,下面显示了新的评论列表:

如果我们点击创建新的链接,我们将被带到我们的Review区域中我们的新RecipeController返回的Create视图:

并且,如果我们点击创建按钮,我们将返回到Detail视图,该视图现在将显示我们的审查:

总结
我们从在路由表中添加路由开始本章的工作。这条新路由为我们的用户提供了一个有意义和可预测的网址,允许他们根据风格过滤其他用户贡献给我们应用的食谱。我们还了解到,创建这些有意义的网址有助于优化我们的搜索引擎网站。
我们的应用也分为几个区域。这些领域将管理食谱和评论的关注点分开,并将使我们的应用在未来更容易维护。
在下一章中,我们将通过向控制器和视图添加用户输入验证来继续改进我们的应用。
八、验证用户输入
永远不要相信外部来源提供给你的应用的数据。你听过多少次了?然而,我们一次又一次地了解到黑客渗透应用、操作系统甚至整个网络的令人兴奋的新方法。虽然大多数常见的语言确实可以保护您免受缓冲区溢出攻击之类的攻击,但新一代的互联网络应用也带来了一系列新的攻击。
在本章中,我们将了解如何使用在System.ComponentModel.DataAnnotations命名空间中找到的数据验证属性来验证提交给我们的应用的数据。接下来我们将看看 ASP.NET MVC 4 框架提供给我们的用于帮助防止跨站点请求伪造 ( CSRF )和跨站点脚本 ( XSS )攻击的工具。
在这一章的最后,你真的只需要记住一条规则——这条规则已经说过了,但需要重复,那就是永远、永远、永远不要相信外部来源提供给我们应用的数据。
数据验证
为什么我们可能想要验证我们的数据?首先,我们的应用可能需要根据需要向用户标识一些字段。我们可能还想通知用户,他们输入了一个无效的配方原始比重的数字,或者他们输入的是文本而不是数字。给用户的反馈不仅保护我们的应用不被捕获无效或恶意数据,还通过在尝试保存数据之前通知用户发生了错误来提供更好的用户体验。
我们的数据验证将通过利用数据注释属性来完成。
数据标注
在System.ComponentModel.DataAnnotations中定义的数据注释已经在视图模型的应用中得到一定程度的利用,为视图渲染器提供显示和格式提示。您可以在下面的代码中看到这一点:
[Display(Name = "Original Gravity")]
[DisplayFormat(DataFormatString = "{0:0.00##}")]
public float OriginalGravity { get; set; }
我们现在将研究使用数据注释验证属性来进一步定义我们的数据模型和视图模型。
这是我们建筑结构的第一个挑战。我们已经将我们的数据模型从我们的领域实体中分离出来。这些域实体从视图模型中分离出来。因为数据注释是属性,所以我们似乎需要将属性应用于数据模型、实体和视图模型,以确保它们保持同步。幸运的是,事实并非如此。
元数据类型属性
MetadataType属性本身是一个数据注释属性,它允许我们为外部类(有时也称为伙伴类)中的一个类的属性指定属性。这些伙伴类包含与它们将应用到的实际类(或多个类)中的属性具有相同名称和类型的属性。
这可能看起来有点混乱,所以让我们来看看我们的BrewHow.Models.Recipe类的一个版本,它使用MetadataType属性来标识一个伙伴类。
[MetadataType(typeof(RecipeValidationMetadata))]
public class Recipe
在RecipeValidationMetadata类中,我们定义了与Recipe*ViewModel类共享的Recipe类属性的所有限制、范围和要求:
public class RecipeValidationMetadata
{
[Required]
[StringLength(128)]
public string Name { get; set; }
[Required]
[Range(1.0f, 2.0f)]
public float OriginalGravity { get; set; }
[Required]
[Range(1.0f, 2.0f)]
public float FinalGravity { get; set; }
[Required]
public string GrainBill { get; set; }
[Required]
public string Instructions { get; set; }
}
在我们的验证伙伴类中,我们已经根据需要标记了所有属性,并在适当的地方设置了范围和长度。这些属性共享在Recipe中定义的属性的名称和类型。这些验证也可以应用于我们的RecipeEditViewModel类,使用相同的MetadataType属性,如下所示:
[MetadataType(typeof(RecipeValidationMetadata))]
public class RecipeEditViewModel
更新数据库
实体框架将使用元数据值来更好地定义我们数据库中的字段。我们需要运行Add-Migration来生成迁移脚本,但是对于这个迁移,我们实际上需要编辑Up方法。作为元数据的一部分,我们制作了所需的Instructions和GrainBill属性。这将数据库中的相应字段设置为不允许空值。将以下代码放在Up方法的开头将确保数据库中已经存在的任何数据的迁移成功:
Sql("update dbo.Recipes set GrainBill = 'No grain bill.' where GrainBill is null");
Sql("update dbo.Recipes set Instructions = 'No instructions.' where Instructions is null");
我们配置的Seed方法也需要更新。请始终记住Seed方法在每次应用迁移后运行,除非我们另有指示。在Seed方法中的每个Recipe类中,确保添加Instructions和GrainBill属性的值。
我们现在可以运行Update-Database来应用我们的约束。EF 迁移过程将从应用于我们的迁移的RecipeValidationMetadata类中提取验证属性,并将它们应用于我们的数据库模式。
我们可以通过查看数据库中的表定义来验证这些更改,如下所示:

验证验证
如果我们在创建菜谱的时候启动 app 并尝试提交一些无效的数据,你会看到 MVC 框架足够善良,可以使用 JavaScript 和 jQuery 在浏览器中验证我们的数据,并将验证错误告知用户。如果我们选择更改呈现给用户的错误消息,我们可以修改数据注释属性以显示适当的错误消息。目前,默认消息是可以接受的。

要了解这是如何工作的,您可以查看 RecipeController 的View文件夹中的Create.cshtml视图。在视图的最底部,您会发现以下标记:
@section Scripts {
@Scripts.Render("~/bundles/jqueryval")
}
这个包将 jQuery 不引人注目的验证库添加到页面中。当我们的页面被渲染时,我们放置在视图模型上的验证属性被EditorFor HTML 帮助器转换成 jQuery 验证标记。提交表单时,会根据这些属性验证字段。
请注意,发生的唯一验证是客户端。对于用户可能已经禁用了 JavaScript 或者有人试图通过绕过我们的视图来发现漏洞的情况,我们希望在服务器上收到数据后立即对其进行验证,如果存在验证错误,则将其发送回客户端。
服务器验证
服务器上的验证相当简单。我们已经在使用 MVC 模型绑定器将 HTTP POST 的内容映射到我们的视图模型中。当模型绑定器这样做时,它会为它试图绑定的每个模型设置一个ModelStateDictionary对象,并将出现的任何错误记录到字典中。字典通过ModelState属性暴露给我们的控制器。
当我们的动作方法被调用并且我们想要验证动作方法的输入是否有效时,我们可以调用ModelStateDictionary上的IsValid属性来确定我们是否应该继续将数据交给域,或者我们是否应该将输入返回给用户以修复任何错误或遗漏:
public ActionResult Create(RecipeEditViewModel recipe)
{
try
{
if (ModelState.IsValid)
{
this._recipeRepository.Save(
ToEntity(recipe));
return RedirectToAction("Index");
}
}
catch
{
}
return View(recipe);
}
注
我们当前的服务器端验证与控制器紧密相关。对于企业级验证场景,我们将抽象验证和用于转换到域实体和视图模型以及从域实体和视图模型转换的映射器,以更好地促进测试和代码重用。
我们现在受到保护,不会有错误的信息通过用户输入进入我们的系统,但我们仍然面临其他形式的恶意输入,我们必须保护我们的用户和我们自己。
跨站点请求伪造(CSRF)
跨站点请求伪造是一种攻击,在这种攻击中,用户的浏览器被秘密地指向在该用户不知情的情况下检索信息或在站点上执行操作。在这些类型的攻击中,用户被认为可以访问目标站点。也许用一个例子来解释更好。
让我们假设贝德福德瀑布当地银行和信托的一名成员刚刚在他们的浏览器中访问了该银行的网站。用户登录,执行一些操作,并且从未明确退出,将身份验证 cookie 留在其浏览器的缓存中。后来,在网上冲浪时,他们访问了一个信誉可疑的网站。
在这个网站上,有人放置了一个脚本文件,通过 AJAX 调用向贝德福德瀑布地方银行和信托公司网站提交资金转移请求。该脚本没有用户可以看到的可见操作。但是,从技术上讲,用户仍然登录到银行网站,并且该脚本成功地从用户的帐户执行了资金转移。就任何人而言,用户执行了这个操作,并且他们是从他们的家庭 IP 地址执行的。这次袭击留下的脚印很少,很可能会被忽视,直到为时已晚。
我们希望防止针对我们应用的此类攻击。并不是说我们有什么高价值的东西需要保护。然而,我们不想让我们的应用以任何影响我们用户的方式受到损害,无论是通过损害他们的数据,还是将损害或攻击归咎于他们。
再一次,ASP.NET MVC 框架带着另一个便利的属性来拯救。
验证机构凭证
ValidateAntiForgeryToken属性可以放在单个动作方法或整个控制器上:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create(RecipeEditViewModel recipe)
该属性与HtmlHelper.AntiForgeryToken()方法协同工作:
@model BrewHow.ViewModels.RecipeEditViewModel
@{
ViewBag.Title = "Create";
}
<h2>Create</h2>
@using (Html.BeginForm()) {
@Html.AntiForgeryToken()
@Html.ValidationSummary(true)
当一个响应被发布到一个用ValidateAntiForgeryToken修饰的动作方法时,该属性会查看发布的数据是否有有效的请求验证标记——由AntiForgeryToken HTML 帮助器添加到页面的标记。如果找不到有效的令牌,就像 CSRF 的情况一样,运行时会在调用 action 方法之前引发异常。

但是,如果找到了令牌,如下面的源代码所示,提交将会成功:
<form action="/Recipe/Create" method="post">
<input name="__RequestVerificationToken" type="hidden" value="31Kxb3eIeUEmZIfY5QwBj_kVGUgKsHxvLAkOjEtX7ZCPb6AJz9vUVD5M7DW6OedEUnlhqKlaP_k1_aGelGeoWDuzMJFvxKoUSCocittWPCo1" />
<fieldset> <!-- … --!>
这个令牌的放置对用户来说是透明的,但它确实保护他们免受劫持他们的应用身份验证票的天真尝试。
类型
允许 AJAX
包含防伪标记并不妨碍您使用 AJAX 功能提交表单来对抗需要防伪标记的操作。你仍然可以这样做。您只需将RequestVerificationToken键/值对添加到开机自检请求中。
跨站点脚本(XSS)
跨站点脚本是当用户将客户端脚本注入页面,试图收集信息或感染其他用户的计算机时可能发生的攻击。XSS 攻击可能导致的具体情况包括强制下载病毒和僵尸工具、窃取包含用户身份信息和/或登录凭据的 cookies,或者修改网站内容的能力。
当用户被允许将 HTML 内容作为表单提交的一部分提交到网站时,通常会发生 XSS 攻击。
假设我们想让用户在配方创建和编辑视图的GrainBill和Instruction字段中向我们的应用提交格式化的 HTML。如果我们不小心实现,用户可能会提交带有嵌入脚本的 HTML 内容,这些脚本可能会被用来劫持用户的会话。
即使我们只让受信任的用户提交 HTML 内容,我们仍然会暴露自己,因为没有什么能阻止我们受信任的用户的浏览器被另一个网站劫持,然后被指向我们的应用。
由于防止 XSS 攻击异常困难,默认情况下,ASP.NET MVC 框架只是阻止 HTML 的提交。
任何 HTML 提交都会导致类似于下面屏幕截图所示的响应:

如果我们决定用户有必要向我们的应用提交 HTML,我们可以采取几个步骤来启用该功能。启用 HTML 提交意味着我们仍然需要编写自己的 HTML 消毒剂,以确保没有恶意内容被上传到我们的网站。以下信息简单来说就是:信息。虽然我们将学习如何启用 HTML 提交,但我们不会在本章以外的应用中启用它。
类型
不要做
我不建议尝试实现你自己的 HTML 消毒剂。整个团队都在开发工具来适当地净化发布的 HTML 内容,以移除恶意内容。杀毒软件不仅要删除格式正确的 HTML,还需要删除格式不正确的 HTML。你可以感谢遗留浏览器的松散实现的渲染引擎。
验证属性
允许提交 HTML 的最简单的方法是使用ValidateInput属性。此属性可以在操作方法和控制器级别启用或禁用输入验证。我们可以修改我们的 RecipeController 的Create操作方法,以绕过输入验证:
[HttpPost]
[ValidateInput(false)]
[ValidateAntiForgeryToken]
public ActionResult Create(RecipeEditViewModel recipe)
现在,当我们像以前一样用 HTML 创建一个新的食谱时,提交是成功的。
使用ValidateInput属性应该是最后的手段。问题是通过使用 ValidateInput我们已经允许发送到动作的所有输入绕过输入验证。我们现在不仅要净化GrainBill和Instruction属性,还要净化Title领域。如果我们在以后的某个时候向视图和模型中添加一个新的字段,我们将不得不记住为它净化输入。
更好的方法是显式标记我们模型的属性,以允许输入 HTML。
allowtml
可以将 AllowHtml属性添加到视图模型的属性中,以通知模型绑定器绕过该属性和该属性的输入验证。这个显式声明比ValidateInput方法更安全,应该在ValidateInput之前使用。
为了允许在我们的GrainBill和Instructions属性的值中使用 HTML,需要修改RecipeEditViewModel类以包含AllowHtml输入。确保您删除了我们几分钟前添加的ValidateInput属性:
[AllowHtml]
[Display(Name = "Grain Bill")]
[DataType(DataType.MultilineText)]
public string GrainBill { get; set; }
[AllowHtml]
[Display(Name = "Instructions")]
[DataType(DataType.MultilineText)]
public string Instructions { get; set; }
当我们在GrainBill和Instructions属性中提交包含格式化 HTML 的配方时,我们成功返回到我们的列表中(当我们尝试在Name属性中提交 HTML 时,我们会收到一个异常)。
如果我们通过点击列表视图中的名称来查看配方的详细信息,我们会看到我们确实提交并保存了 HTML,但是该 HTML 是显示的,而不是呈现的。

我们需要修改视图,以原始格式显示字段。
原始格式
如果你回想起前几章,我们讨论了剃刀引擎如何自动编码我们的属性。该编码将尖括号(< >)转换为<和>。这些代码随后在浏览器中分别呈现为<和>,并且不指示浏览器呈现预期的 HTML。我们需要修改视图以呈现属性的原始内容,并使用HtmlHelper类上的Raw方法绕过 HTML 编码。
在再循环控制器的Details视图中,我们需要修改Instruction和GrainBill属性的输出,以使用Html.Raw代替Html.DisplayFor:
<div class="display-label">
@Html.DisplayNameFor(model => model.GrainBill)
</div>
<div class="display-field">
@Html.Raw(Model.GrainBill)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.Instructions)
</div>
<div class="display-field">
@Html.Raw(Model.Instructions)
</div>
现在当我们查看Details视图的输出时,我们的 HTML 提交被适当地渲染,如下图所示:

既然我们知道它有效,我们就可以撤销它。如果没有对输入进行适当的清理,我们不希望只允许提交任何 HTML 标记。
总结
在本章中,我们学习了如何验证我们的应用从用户那里接收到的输入。从这个意义上来说,验证不仅意味着确保数据的格式正确,还意味着它可以被认为是安全的或尽可能安全的,因为规则 1 同样是不信任用户提交的数据。
在下一章中,我们将了解我们的应用的授权和认证,包括对使用谷歌帐户进行认证的支持。我们将使用这些功能来防止匿名用户创建和编辑食谱,并防止用户编辑其他人的食谱。我们还将允许经过身份验证的用户创建一个食谱库,以便快速访问他们的收藏夹。
九、识别和授权用户
到目前为止,BrewHow 一直是一个完全匿名的应用。任何人都可以发布食谱与社区分享,任何人都可以查看这些食谱。如果这是我们希望向用户提供的唯一功能,但我们有更高的期望,那么这个模型就很好。我们希望我们的用户的贡献得到认可,并对他们的评论负责。我们可能还会添加要求应用知道他们是谁的功能。我们需要为用户提供一种使用我们的应用进行身份验证的方法。
在本章中,我们将探讨为我们的应用提供身份验证和授权的 ASP.NET 成员资格框架。我们将使用SimpleMembership会员提供商在我们的应用中创建新用户,并查看对外部认证提供商(如谷歌和脸书)的新支持。然后,我们将把应用的一部分限制为经过身份验证的用户。我们将把我们所学的一切结合起来,让用户创建一个食谱库来存储他们最喜欢的食谱,从而完成这一章。
用户认证
用户认证允许我们验证用户就是他们所说的。我们可以通过多种方式在应用中验证用户身份,但典型的情况(我们将重点关注的情况)是允许用户使用用户名和密码在应用中验证自己。
ASP.NET 支持两种现成的身份验证:Windows 身份验证和表单身份验证。
Windows 身份验证
当使用 Windows 身份验证时,应用根据 Windows 域控制器验证用户的身份。在这种情况下,用户的 Windows 凭据被用于尝试向应用进行身份验证。这种类型的身份验证通常用于集中管理用户的内部网场景。
表单认证
表单身份验证是您最可能熟悉的身份验证类型。表单身份验证允许用户向系统提供他们选择的用户名和密码。成功认证后,用户将被分配一个 cookie,浏览器将在以后的任何验证用户身份的请求中提交该 cookie。
这种类型的身份验证主要用于为互联网设计的应用。鉴于我们的应用将从互联网访问,我们将使用表单身份验证。
验证酿酒用户
开箱即用,使用互联网应用模板构建的应用支持表单认证,并为用户登录和创建我们的应用帐户提供便利。我们可以通过查看web.config文件中的authentication节点来验证这一点:
<system.web>
<!-- -->
<authentication mode="Forms">
<forms loginUrl="~/Account/Login" timeout="2880" />
</authentication>
<!-- -->
</system.web>
认证节点将认证模式设置为Forms认证,将我们 app 的登录 URL 设置为~/Account/Login。查看AccountController类的Login操作,我们看到该操作正在为用户调用WebSecurity.Login方法,并将他们重定向到登录成功后他们最初尝试访问的网址:
if (ModelState.IsValid && WebSecurity.Login(model.UserName, model.Password, persistCookie: model.RememberMe))
{
return RedirectToLocal(returnUrl);
}
用于认证用户的WebSecurity类是SimpleMembership应用编程接口的一部分,该接口是从WebMatrix项目并入 ASP.NET MVC 4 的成员资格提供者。
简单会员资格
SimpleMembership 提供了一套更小、更好的工具来管理用户账户,比之前版本的 ASP.NET 提供的工具更好。与最初的 ASP.NET 成员资格提供者应用编程接口需要一个支持存储过程的 SQL Server 版本不同,SimpleMembership可以与 SQL Server、SQL CE、LocalDB 和任何其他具有实体框架提供者的数据库一起工作。
初始化SimpleMembership数据库就像调用WebSecurity.InitializeDatabaseConnection一样简单。在我们的应用中,这个调用目前发生在位于我们解决方案的Filters文件夹中的InitializeSimpleMembershipAttribute类中:
WebSecurity
.InitializeDatabaseConnection(
"DefaultConnection",
"UserProfile",
"UserId",
"UserName",
autoCreateTables: true
);
InitializeDatabaseConnection方法定义如下:
public static void InitializeDatabaseConnection(
string connectionStringName,
string userTableName,
string userIdColumn,
string userNameColumn,
bool autoCreateTables
)
在我们的InitializeSimpleMembershipAttribute中,我们告诉SimpleMembership提供者使用连接字符串DefaultConnection初始化数据库。用户档案信息将存储在UserProfile表的UserId和UserName字段中。我们还指示SimpleMembership通过将参数autoCreateTables设置为真来创建包含所有成员信息的表格。
当然,如果属性中的代码从不执行,所有这些设置都没有什么意义。为确保成员表在需要时存在,互联网应用模板将InitializeSimpleMembershipAttribute应用于AccountController:
[Authorize]
[InitializeSimpleMembership]
public class AccountController : Controller
通过将其应用于AccountController类,代码确保了当且仅当帐户控制器被调用时,成员资格表才被创建。这为我们的成员资格提供者提供了一种惰性初始化的形式。它还为我们提供了在不创建成员模式的情况下更改应用以使用 Windows 身份验证的机会。
注
成员资格表将在首次调用帐户控制器时创建。如果您希望使用 Windows 身份验证,请在启动应用之前更改web.config文件。
成员模式中的表通过UsersContext访问,这是在我们解决方案的Models文件夹中的AccountModel.cs文件中定义的辅助 EF 上下文:
public class UsersContext : DbContext
{
/* ... */
public DbSet<UserProfile> UserProfiles { get; set; }
}
在我们的测试中,这个上下文实际上已经被调用了几次,所以我们应该已经创建了我们的成员模式。点击解决方案资源管理器中的显示所有文件按钮,显示我们解决方案中的所有隐藏文件:

如果您现在展开App_Data文件夹,您应该会看到实际上有两个数据库,一个用于 BrewHow 上下文,另一个保存成员模式:

双击会员数据库,在数据库浏览器中打开。

您应该注意的第一件事是数据库包含在UsersContext上下文中定义的UserProfile表。我们还有另外四个由SimpleMembership提供者在调用InitializeDatabaseConnection时构建的表,因为我们在autoCreateTables参数中传递了一个值true。这些表存储了关于网站成员资格的信息,并支持应用中角色的管理和应用。
定制认证
在这个按需场景中,为我们的应用设置成员身份显然需要很多步骤。由于我们的应用只使用SimpleMembership提供者并实现表单身份验证,因此我们可以去掉支持这种按需场景的代码,并应用一些定制。
简单成员初始化
由于InitializeSimpleMembershipAttribute的存在只是为了提供惰性初始化,简化代码最简单的方法是移除属性并将InitializeDatabaseConnection方法移入Global.asax。
打开Global.asax.cs并在Application_Start方法顶部添加以下代码:
WebSecurity.InitializeDatabaseConnection(
"DefaultConnection",
"UserProfile",
"UserId",
"UserName",
autoCreateTables: true);
由于我们在应用启动时初始化成员数据库,因此我们可以从AccountController定义中删除属性,并从我们的解决方案中删除InitializeSimpleMembershipAttribute.cs文件。
统一上下文
T2 没有理由让我们的应用有两个独立的数据库。我们应用的用户和他们创造的食谱是交织在一起的。我们可以统一数据库,这样做可以统一BrewHowContext和UsersContext——这两个上下文当前用于存储和检索数据库中的信息。
我们将从更改web.config文件中的DefaultConnection连接字符串开始。我们希望将数据库统一到一个名为BrewHow.mdf的新数据库文件中,目录名为BrewHow。下面的代码显示了新的连接字符串:
<connectionStrings>
<add name="DefaultConnection" connectionString="Data Source=(LocalDb)\v11.0;Initial Catalog=BrewHow;Integrated Security=SSPI;AttachDBFilename=|DataDirectory|\BrewHow.mdf" providerName="System.Data.SqlClient" />
</connectionStrings>
我们修改了DefaultConnection连接字符串,因为它实际上是配置文件中唯一的连接字符串。成员资格功能已经在使用这个连接字符串,但是BrewHowContext类正在寻找一个名为BrewHow.Models.BrewHowContext的连接字符串。我们在第 4 章、在 EF5 中建模酿酒。
为了告诉 BrewHow 上下文使用DefaultConnection连接字符串,我们需要修改BrewHowContext构造函数,如下代码所示:
public BrewHowContext()
: base("DefaultConnection")
{
}
下一步是将UserProfile类添加到BrewHowContext中,该类代表我们对InitializeDatabaseConnection的调用中定义的应用的用户。该类目前在AccountModels.cs文件中定义。因为它实际上是我们数据模型的一部分,我们应该把它移到Models文件夹中自己的文件中。在我们的Models文件夹中创建一个名为UserProfile.cs的新文件,并将AccountModels.cs中定义的UserProfile类移动到该文件中:
[Table("UserProfile ")]
public class UserProfile
{
[Key]
[DatabaseGeneratedAttribute
(DatabaseGeneratedOption.Identity)]
public int UserId { get; set; }
public string UserName { get; set; }
}
为了完成上下文的合并,我们需要将UserProfile类移入BrewHowContext。这要求我们修改BrewHowContext类和IBrewHowContext界面:
public interface IBrewHowContext
{
IDbSet<Models.Recipe> Recipes { get; set; }
IDbSet<Models.Review> Reviews { get; set; }
IDbSet<Models.Style> Styles { get; set; }
IDbSet<Models.UserProfile> UserProfiles { get; set; }
int SaveChanges();
}
public class BrewHowContext : DbContext, IBrewHowContext
{
public IDbSet<Models.Recipe> Recipes { get; set; }
public IDbSet<Models.Review> Reviews { get; set; }
public IDbSet<Models.Style> Styles { get; set; }
public IDbSet<Models.UserProfile> UserProfiles { get; set; }
public BrewHowContext()
: base("DefaultConnection")
{
}
/* ... */
}
在所有的更改中,更改连接字符串实际上是最具破坏性的一个。我们对上下文做了一些更改,但是由连接字符串标识的数据库实际上并不存在。这意味着真的没有什么可迁移的。考虑到我们已经应用的迁移都是在我们的应用开发中逐步增加的,我们没有做任何可以回滚的事情;我们仍在探索酿酒的领域。是时候用一个新数据库重新开始了。
首先从Migrations文件夹中删除所有迁移文件。不要删除Configuration.cs文件,因为我们的Seed方法仍然有效。打开包管理器控制台,运行Add-Migration命令设置初始迁移。一旦命令完成并创建了初始迁移,运行Update-Database命令来创建我们统一的 BrewHow 数据库模式,并用我们的样本数据播种它。
用户档案库
为了与我们既定的设计模式保持一致,我们需要为UserProfile数据模型创建一个存储库。检查AccountController中的代码,我们看到我们的应用目前支持通过用户名创建和检索用户配置文件。该功能需要移动到存储库中。遵循我们项目中的其他模式,用户配置文件的存储库也需要支持我们的数据模型到我们的领域模型的编组。IUserProfileRepository的接口在下面的代码中定义。实现和其他存储库一样简单,可以在本书附带的代码中找到:
public interface IUserProfileRepository
{
UserProfileEntity GetUser(string username);
void Save(UserProfileEntity userProfileEntity);
}
UserProfileEntity类型是UserProfile模型的域实体,包含一组相同的字段:
public class UserProfileEntity
{
public int UserId { get; set; }
public string UserName { get; set; }
}
帐户控制器上下文
最后一步是修改我们的AccountController类,使用我们的IUserProfileRepository接口的注入实现。打开AccountController类,添加一个接受IUserProfileRepository参数的构造函数。确保将参数分配给类的成员变量:
private readonly IUserProfileRepository _userProfileRepository;
public AccountController(
IUserProfileRepository userProfileRepository)
{
this._userProfileRepository = userProfileRepository;
}
我们整个项目中唯一使用UsersContext的位置是在AccountController的ExternalLoginConfirmation方法中。它确定具有给定用户名的用户是否存在,然后创建该用户或返回一个错误,指示该用户已经存在于数据库中:
using (UsersContext db = new UsersContext())
{
UserProfile user = db.UserProfiles.FirstOrDefault(u => u.UserName.ToLower() == model.UserName.ToLower());
if (user == null)
{
// Create the new user.
}
else
{
// Return an error.
}
需要调整该代码,以使用传递给控制器构造器的IUserProfileRepository的实现:
UserProfileEntity user = this
._userProfileRepository
.GetUser(model.UserName.ToLower());
if (user == null)
{
this._userProfileRepository.Save(
new UserProfileEntity
{
UserName = model.UserName
});
// Do other stuff.
}
else
{
// Return an error.
}
按下键盘上的 Ctrl + F5 或从 DEBUG 菜单中选择开始不调试来初始化成员表,打开浏览器中的应用。成功启动应用后,展开App_Data目录,您将看到我们新的统一数据库:

双击酿酒数据库,打开数据库浏览器。如果您展开表节点,您将看到我们的应用在单个数据库中运行所需的所有表:

注册并登录
现在成员表已经配置好了,我们应该可以注册一个账户并登录了。首先启动应用,点击页面顶部的注册链接:

在注册页面上,输入要创建的用户的凭据。完成后,点击注册按钮:

点击 注册按钮后,您将自动登录到该应用,并获得新用户名:

有效!不幸的是,你也被带到了旧的/ Home/Index动作——原来的主页我们换成了我们的/Recipe/Index动作。互联网应用模板中有几个我们需要替换的神奇字符串。在这个特定的场景中,我们需要在三个独立的动作方法中修改AccountController类:LogOff、Register和RedirectToLocal。
在所有这三种操作方法中,我们需要替换以下代码:
return RedirectToAction("Index", "Home");
前面的代码将被下面的代码替换:
return RedirectToAction("Index", "Recipe", new { area="" });
现在,所有注册、登录和注销操作都将用户重定向到RecipeController's Index操作,我们的SimpleMembership提供商现在成功地为我们的应用提供了身份验证服务。我们甚至可以在数据库浏览器中打开UserProfile表,看到我们刚刚创建的用户:

除了在我们的数据库中提供用户名和密码支持之外,SimpleMembership提供商还可以扩展到允许我们针对外部来源(如谷歌和脸书)进行身份验证。
外部认证
为我们的应用启用外部认证相当简单。如果我们打开App_Start文件夹中的AuthConfig.cs文件并查看RegisterAuth方法,我们将看到四个可用的外部提供程序:
public static void RegisterAuth()
{
// To let users of this site log in using their accounts
// from other sites such as Microsoft, Facebook, and
// Twitter, you must update this site. For more information
// visit http://go.microsoft.com/fwlink/?LinkID=252166
//OAuthWebSecurity.RegisterMicrosoftClient(
// clientId: "",
// clientSecret: "");
//OAuthWebSecurity.RegisterTwitterClient(
// consumerKey: "",
// consumerSecret: "");
//OAuthWebSecurity.RegisterFacebookClient(
// appId: "",
// appSecret: "");
//OAuthWebSecurity.RegisterGoogleClient();
}
我们将为啤酒之路启用谷歌认证。
注册外部账户
要启用谷歌认证,我们需要取消RegisterAuth方法中OAuthWebSecurity.RegisterGoogleClient();行的注释,并重新编译应用。当我们启动应用并点击登录链接时,我们的对话框现在为我们提供了使用谷歌帐户登录的能力。

点击标签为谷歌的按钮,我们将进入谷歌登录页面:

在输入我们的帐户信息后,谷歌以典型的 OAuth 方式要求我们授予 BrewHow 应用某些权限。在这种情况下,它要求向酿酒公司提供我们的电子邮件地址:

点击接受将带我们回到我们的应用,在那里我们被要求提供用户名。我们链接帐户的默认用户名是我们的谷歌帐户地址:

接受默认值并点击注册按钮,我们将使用谷歌帐户登录到 BrewHow。

如果现有用户已经有了一个 BrewHow 帐户,并希望使用他们的谷歌帐户,我们还可以提供将外部帐户与其现有的 BrewHow 帐户相关联的功能。
关联外部账户
如果您当前使用谷歌帐户登录应用,请退出应用,并使用您创建的本地帐户登录。如果您继续关注,我们在本章前面创建的本地帐户的用户名为 brewmaster 。我们将转到我们的个人资料,将一个谷歌帐户与我们当地的酿酒公司帐户相关联。
要查看您的个人资料,请在问候语中单击您的用户名。在你的账户资料上,滚动到底部,你会看到一个标有谷歌的按钮。点击此按钮将允许您将您的谷歌帐户与您的本地帐户相关联:

我们现在可以成功地在 BrewHow 中验证用户。是时候批准他们的行动了。
授权
认证为我们提供了一种识别用户的手段,但是授权为我们提供了一种机制来启用或限制认证用户可能执行的操作。
限制访问
在 ASP.NET MVC 中,的访问是通过使用可放在控制器或动作上的Authorize属性来限制的。如果Authorize属性处于控制器级别,匿名用户可以通过AllowAnonymous关键字被授予特定操作的访问权限。
授权属性
如果你看一下AccountController类,你会看到用Authorize属性声明的类。但是,Login的动作是用AllowAnonymous属性修饰的:
[Authorize]
public class AccountController : Controller
{
[AllowAnonymous]
public ActionResult Login(string returnUrl)
{
ViewBag.ReturnUrl = returnUrl;
return View();
}
/* ... */
}
Authorize属性的应用表明,只有经过身份验证的用户才能访问帐户控制器。AllowAnonymous属性用作Authorize属性的覆盖,并授予对Login动作的匿名访问权。
授权用户投稿
在 BrewHow 应用中,所有匿名用户都应该能够浏览网站,查看菜谱。然而,我们希望只有经过认证的用户才能够贡献食谱或提供评论。
在我们的解决方案的Controllers文件夹中打开RecipeController类,并对两个Edit动作和两个Create动作应用一个Authorize属性。这将使这些操作仅限于经过身份验证的用户。同样,在我们的解决方案的Review区域打开RecipeController类,并将AuthorizeAttribute应用于两个Create动作。以下是来自Review区域的RecipeController的部分副本,以说明Authorize属性的应用:
public class RecipeController : Controller
{
[HttpGet]
[Authorize]
public ActionResult Create(int id)
{
return View();
}
[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
public ActionResult Create(int id,ReviewEditViewModel reviewEditViewModel)
{
/* ... */
}
/* ... */
}
在浏览器中打开我们的应用,确保您当前没有登录。在主页上,点击标记为新建的链接,尝试创建一个食谱。系统将提示您登录。系统会提示您登录,因为该操作现在需要对用户进行身份验证。
当然,向用户展示他们不能执行的动作是不好的形式。我们需要清理视图,以隐藏匿名用户无法使用的任何活动。
注
Authorize属性还允许您通过指定允许的角色或用户组来进一步限制对控制器或操作的访问。只需将用户或角色作为[Authorize(Users="andy, brewmaster")]或[Authorize(Roles="Administrators")]传递给属性。BrewHow 应用将只限制认证用户访问,因此[Authorize]的使用足以满足我们的需求。
清理界面
打开查看RecipeController的Index动作方法,找到以下代码:
@Html.ActionLink("Create New", "Create")
这是在RecipeController的Index视图上显示新建链接的代码部分。要对未经身份验证的用户隐藏此链接,请将代码更改为以下内容:
@if (Request.IsAuthenticated) {
@Html.ActionLink("Create New", "Create")
}
当我们在浏览器中打开应用时,假设我们没有登录,链接会消失:

我们可以通过登录并返回到我们的Index视图来验证代码是否有效:

确保将相同的逻辑应用于配方详细信息屏幕上的编辑链接和部分视图上的添加审核链接进行审核。
内容所有权
可以说酿酒应用已经被锁定了。我们只允许经过身份验证的用户创建和编辑食谱或提交评论。然而,缺乏任何所有权的概念;任何用户都可以编辑任何配方。我们的用户提交食谱是为了分享,而不是让其他用户修改。我们网站上的内容需要保护,只允许最初提交内容的用户编辑内容。我们需要引入所有权的概念。
注
虽然所有权确定可以用于启用或禁用大量功能,但我们的所有权实现只关心实体是否可以编辑。
实现所有权
为了让启用所有权,我们需要修改我们的模式,将UserProfile与Recipes和Reviews相关联。这将允许我们指定一个用户作为配方的贡献者或审阅者。
打开Models文件夹中的Recipe类,添加一个类型为UserProfile的Contributor属性。这将在配方和创建它的用户之间建立关系:
public UserProfile Contributor { get; set; }
我们还需要建立Review和UserProfile之间的关系,以确定提交评论的用户。向Review类添加以下属性可以实现这一点:
public UserProfile Reviewer { get; set; }
一旦创建,我们需要将Recipe和Review的UserProfile属性映射到RecipeEntity和ReviewEntity上的UserProfileEntity属性。该映射将发生在ReviewRepository和RecipeRepository内部。这种模型到实体的映射在本书附带的代码中介绍。
用户配置文件模式映射
留给它自己的设备,实体框架将做足够的工作来创建UserProfile和现在引用它的其他模型之间的关系。然而,我们想对这个过程施加一点控制。我们需要修改BrewHowContext中的OnModelCreating方法来指定外键的名称。这些修改将确保Recipe和Review车型需要UserProfile:
modelBuilder.Entity<Recipe>()
.HasRequired(s => s.Contributor)
.WithMany()
.Map(m => m.MapKey("ContributorUserId"));
modelBuilder.Entity<Review>()
.HasRequired(r => r.Reviewer)
.WithMany()
.Map(m => m.MapKey("ReviewerUserId"));
并非所有事情都像添加新属性和映射值那样简单。UserProfile创建由SimpleMembership应用编程接口处理,前面的代码根据需要标记了Recipe或Review与UserProfile之间的关系。这意味着我们的数据库在Seed方法中的当前种子不会像它写的那样工作。需要对Seed方法进行调整,以支持与UserProfile模型的新的所需关系。
播种用户
播种SimpleMembership表比调用 API 稍微复杂一点。如果我们简单地将以下内容添加到Seed方法中,调用包管理器控制台中的Update-Database命令会抛出一个错误:
WebSecurity.InitializeDatabaseConnection(
"DefaultConnection",
"UserProfile",
"UserId",
"UserName",
autoCreateTables: true);
var membership = (SimpleMembershipProvider)
Membership.Provider;
if (membership.GetUser("brewmaster", false) == null)
{
membership
.CreateUserAndAccount(
"brewmaster",
"supersecret"
);
}
出现这种情况是因为运行Update-Database命令的上下文没有选择默认的成员配置。通过在我们的system.web部分的web.config中明确声明会员提供商信息,我们可以解决这个问题:
<roleManager enabled="true"
defaultProvider="SimpleRoleProvider">
<providers>
<clear/>
<add name="SimpleRoleProvider"type="WebMatrix.WebData.SimpleRoleProvider, WebMatrix.WebData"/>
</providers>
</roleManager>
<membership defaultProvider="SimpleMembershipProvider">
<providers>
<clear/>
<add name="SimpleMembershipProvider" type="WebMatrix.WebData.SimpleMembershipProvider, WebMatrix.WebData" />
</providers>
</membership>
如果我们现在将本节开头的代码插入到我们的Seed方法中,我们可以使用SimpleMembership API 插入用户。然后,我们的Recipe的种子可以定位该用户,并将其分配给配方的Contributor属性:
var brewMaster = context.UserProfiles.First(u => u.UserName == "brewmaster");
/* ... */
context.Recipes.AddOrUpdate(
recipe => recipe.Name,
new Recipe
{
Name = "Sweaty Brown Ale",
Style = brownAle,
OriginalGravity = 1.05f,
FinalGravity = 1.01f,
Slug = "sweaty-brown-ale",
Instructions = "None",
GrainBill = "None",
Contributor = brewMaster
},
/* ... */
);
应用所有权迁移
配置好模型之间的关系后,我们准备创建一个迁移来表示所有权。通过在包管理器控制台窗口中运行Add-Migration Ownership来创建新的迁移。
一旦生成了迁移脚本,我们仍然没有准备好将其应用于我们的数据库。请记住,我们的新模型从Recipe和Review模型创建了与UserProfile的必要关系。我们的迁移目前将会失败,因为任何试图在引用UserProfile的列上添加外键约束的操作都将会失败以获取表中的现有行。它们目前在这些外键列中没有值,并且null的值是不可接受的:
AddColumn("dbo.Recipes",
"ContributorUserId",
c => c.Int(nullable: false));
AddColumn("dbo.Reviews",
"ReviewerUserId",
c => c.Int(nullable: false));
AddForeignKey(
"dbo.Recipes",
"ContributorUserId",
"dbo.UserProfile",
"UserId",
cascadeDelete: true);
AddForeignKey(
"dbo.Reviews",
"ReviewerUserId",
"dbo.UserProfile",
"UserId",
cascadeDelete: true);
CreateIndex("dbo.Recipes", "ContributorUserId");
CreateIndex("dbo.Reviews", "ReviewerUserId");
我们需要在外键约束被放在列上之前给它播种。如果我们在AddColumn和AddForeignKey语句之间放置以下代码,我们的问题应该会得到解决:
Sql(@"Update dbo.Recipes
Set ContributorUserId = (
select UserId
from dbo.UserProfile
where username = 'brewmaster')");
Sql(@"Update dbo.Reviews
Set ReviewerUserId = (
select UserId
from dbo.UserProfile
where username = 'brewmaster')");
我们现在可以运行Update-Database命令,一切都会正常。检查数据库,我们看到种子UserProfile确实与Recipe关联为ContributorUserId:

在Seed方法中处理所有权对于样本数据来说很棒,但是我们也需要处理 app 本身内部的内容所有权。
分配所有权
在应用中,所有权通过将UserProfileEntity转让给RecipeEntity的Contributor房产或Review的Reviewer房产来处理。为了构建一个UserProfileEntity,我们将创建一个名为UserProfileEntityFactory的工厂类。这个类首先检查以确保我们是在一个 HTTP 请求的上下文中操作的(如果我们不是,那么我们将不能构造实体)。如果我们不是,那么我们将无法构建实体。然后,它从本章开头提到的WebSecurity类中提取当前已认证用户的UserId和UserName:
public class UserProfileEntityFactory : IUserProfileEntityFactory
{
public UserProfileEntity Create()
{
var context = HttpContext.Current;
if (context == null)
{
throw new InvalidOperationException(
"The request is not occurring within a valid HTTP context.");
}
if (!context.Request.IsAuthenticated)
{
return null;
}
return new UserProfileEntity
{
UserId = WebSecurity.CurrentUserId,
UserName = WebSecurity.CurrentUserName
};
}
}
我们工厂的类定义实现了一个新的接口,IUserProfileEntityFactory。这个实现可以被注入到任何需要创建 UserProfileEntity作为IUserProfileEntityFactory抽象的地方。通过使用工厂,我们已经在我们的UserProfileEntity构建中封装了对WebSecurity的依赖。通过注入具体的实现作为接口,我们可以灵活地更改IUserProfileEntityFactory的实现,可能是给不同的安全提供商,而不会对应用产生影响。
并且拥有为用户创建新的UserProfileEntity的能力允许我们如下分配所有权:
recipeEntity.Contributor =
this._userProfileEntityFactory.Create();
一切都准备好了,我们可以在我们的应用中分配内容的所有权。我们现在需要执行这些任务。
强制执行所有权
对于我们来说,要启用所有权,我们需要确定访问内容的用户是否是最初提交内容的用户。这个决定很简单。我们只需要将认证用户的UserId与投稿人的UserId进行比较。如果它们匹配,则经过身份验证的用户是内容的贡献者。如果它们不匹配,用户就不是贡献者。
正如我们在本章前面所做的匿名用户不可用的操作一样,我们需要根据用户是否拥有呈现给他们的内容来限制用户的操作。由于这些操作在视图中呈现,我们需要通知视图用户是否可以执行某些操作。这是通过视图模型完成的。
调整视图模型
食谱有一个Contributor属性,评论有一个Reviewer属性来定义所有权,但是我们的控制器目前允许任何认证的用户编辑内容,无论是他们的还是其他的。我们需要向视图传达用户可能采取的行动,并且我们需要在控制器本身内实施这些行动。
为了告知配方细节视图用户是否可以编辑配方,我们需要修改RecipeDisplayViewModel以包含CanEdit属性。当我们打开视图模型时,让我们继续添加一个Contributor属性来表示贡献者的用户名:
[Display(Name = "Contributor")]
public string ContributedBy { get; set; }
public bool CanEdit { get; set; }
使用CanEdit属性,我们可以调整视图以限制对RecipeController的Display视图上的编辑链接的访问:
@if (Model.CanEdit) {
@Html.ActionLink("Edit", "Edit", new
{
id=Model.RecipeId,
slug = Model.Slug
})
@:|
}
现在编辑 链接只有在CanEdit为真的情况下才能被用户访问,限制只有配方的所有者才能访问。
当然,这还不够。用户,尤其是恶意用户,会找到新的创造性方法来做你不想让他们做的事情。我们需要确保这些所有权规则在后端得到执行。
确保所有权
当从视图模型构建新的RecipeEntity时,使用IUserProfileEntityFactory为Contributor属性分配一个UserProfileEntity实例。然后,配方和贡献者之间的这种关系被保存到数据库中,以便以后通过存储库进行检索。当请求查看配方的详细信息时,Details操作现在拥有设置视图模型的CanEdit属性所需的信息。只需要将WebSecurity的CurrentUserId属性与投稿人的UserId进行比较。如果它们匹配,则当前用户是配方的创建者,并且可以编辑它:
if (Request.IsAuthenticated)
{
viewModel.CanEdit = WebSecurity.CurrentUserId == recipeEntity.Contributor.UserId;
}
很简单。在将CanEdit属性设置为真之前,这段小代码确保用户是内容的贡献者。当然,我们信任我们的用户,但是万一他们无意中试图提交对他们没有贡献的配方的更改,我们需要在将更改提交到存储库之前验证所有权。你知道…以防万一。
验证所有权
验证所有权符合从不信任用户数据的口头禅。这意味着验证所有数据,而不仅仅是我们期望它们改变的数据。考虑用户请求编辑配方的场景。拥有人类可读的网址,或者任何模式很容易被猜到的网址的一个缺点是,用户能够非常容易地找出如何改变它们来找到他们可能感兴趣的信息。
如果您检查响应 HTTP GET 请求的RecipeController的Edit动作,您会注意到该动作采用单个参数:配方的id来检索进行编辑:
public ActionResult Edit(int id)
这很容易改变。任何半智能用户都会意识到,通过将/Recipe/Edit/1更改为/Recipe/Edit/2并将请求提交给服务器,他们可以获得不同的食谱。当操作被调用时,它的责任是验证用户是被请求的配方的所有者:
var recipeToEdit =
this
._recipeRepository
.GetRecipe(id);
if (!CanEdit(recipeToEdit))
{
// Simply return the user to the detail view.
// Not too worried about the throw.
return RedirectToAction("Details", new { id = id });
}
return View(ToEditModel(recipeToEdit));
在我们的Edit操作中,我们加载请求的配方,并尝试验证用户是所有者。如果我们确定用户是所有者,我们会将他们发送到编辑视图,并显示他们选择编辑的配方。如果他们不是所有者,我们只需将他们重定向回Details页面。我们可以抛出一个错误,记录一个安全违规,或者执行一整套其他操作,但是将它们发送回详细信息页面是非常良性的,并且不需要我们付出什么努力。
无论是为任何用户呈现还是持久化编辑,都应该从存储库中加载原始项目。应该根据加载的实体来确定所有权。这对于防止用户改变他们发送回服务器的信息的所有者是必要的。
食谱库
我们的应用即将完成功能。为了让应用为的初始启动做好准备,我们需要添加最后一个特性:配方库。用户可以使用配方库来存储他们在我们网站上找到的配方,以便快速访问。添加这个新特性将把本书到目前为止提供的几乎所有信息整合在一起。我们还将研究一些可以添加到工具带上的新技术。
为了实现这个特性,我们需要调整数据模型来加强用户和食谱之间的关系。然后,我们需要创建一个新的存储库来表示库,并创建一个控制器来根据用户的请求对域进行操作。将有一个视图向用户呈现用户库中的食谱,并允许他们从库中删除食谱。通过修改RecipeController的配方列表,将配方添加到库中。向库中添加和从库中移除配方将利用 jQuery 使这些操作异步。并且,给定一个库与一个用户相关联,与该库相关联的所有动作可能仅由经过身份验证的用户执行。
库数据模型
图书馆是一个抽象的概念。这只是用户和配方之间的关系。它没有名字也没有数据。我们将通过向UserProfile数据模型添加一个Library属性,使库中的食谱集合变得可访问:
public virtual ICollection<Recipe> Library { get; set; }
虽然这是一个抽象的概念,但我们仍然需要一个位置来存储这种关系。我们真正需要维护的关系是用户的 ID 和配方的 ID。该关系将被配置为在名为UserRecipeLibrary的表中存储用户的标识和配方的标识。像我们已经实现的其他映射一样,这个映射将在BrewHowContext类的OnModelCreating方法中设置:
modelBuilder.Entity<UserProfile>()
.HasMany(r => r.Library)
.WithMany()
.Map(
m =>
{
m.MapLeftKey("UserId");
m.MapRightKey("RecipeId");
m.ToTable("UserRecipeLibrary");
});
这个映射告诉实体框架,用户配置文件通过库属性有许多与之相关联的配方。没有互补关系,我们不能得到一个用户的列表,他们的库中有食谱。使用UserId和RecipeId作为关系的键,将该关系映射到UserRelationshipLibrary表中。
要修改数据模型以反映这种关系,请使用您应该非常熟悉的Add-Migration和Update-Database命令。一旦完成,我们就可以开始编写代表这个域实体的存储库。
库存储库
库存储库需要提供一种向用户的库中添加和从用户的库中移除配方的方法,并从库中检索所有可用的配方。它的实现应该很简单,因为这是我们现在实现的第四个存储库。库存储库的界面定义如下:
public interface ILibraryRepository
{
void AddRecipeToLibrary(int recipeId, int userId);
IQueryable<RecipeEntity> GetRecipesInLibrary(int userId);
void RemoveRecipeFromLibrary(int recipeId, int userId);
}
您可以看到没有正在返回的库类。正如我在本节开始时所说的,库是一个抽象的概念。如果我们要扩展这个概念,允许用户添加过滤器、更改名称或创建多个库,我们需要进行适当的模型调整。根据目前的定义,返回一个食谱列表就足以满足我们的需求。
库控制器
创建一个控制器现在应该对已经相当熟悉了。LibraryController需要支持从库中添加和移除配方的能力,以及向用户呈现包含在库中的配方列表。以下是LibraryController支持的三个动作的代码:
public ActionResult Index(int page = 0)
{
var recipesInLibrary = this
._libraryRepository
.GetRecipesInLibrary(WebSecurity.CurrentUserId);
var viewModel = new PagedResult<RecipeEntity, RecipeDisplayViewModel>(
recipesInLibrary,
page,
this._displayModelMapper.EntityToViewModel);
return View(viewModel);
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create(int id)
{
int userId = WebSecurity.CurrentUserId;
this
._libraryRepository
.AddRecipeToLibrary(id, userId);
return Json(new { result = "ok" } );
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Delete(int id)
{
int userId = WebSecurity.CurrentUserId;
this
._libraryRepository
.RemoveRecipeFromLibrary(id, userId);
return Json(new { result = "ok" });
}
如果您通读此文,您可能已经注意到与我们所写的其他控制器的一些小差异。首先,您可能会注意到_displayModelMapper成员变量。请记住,我们的控制器负责将域对象转换为可供查看的对象。碰巧我们的RecipeController和LibraryController在同一个视图模型上运行。因为除非万不得已,否则我们不应该复制代码,所以配方映射功能被从配方控制器转移到了映射类中。为了保持一致,对样式控制器及其映射进行了相同的更改。本章中完成的这个和其他重构工作的代码可以在本书附带的代码中找到。
您可能还会注意到Create和Delete动作正在通过Controller的Json方法返回一个JsonResult。用户库的整个管理将通过对库控制器的 AJAX 调用来完成。我们应该回到过去,为在某个时候禁用了 JavaScript 的用户提供支持,但是我们假设没有必要这样做来使库对我们的大多数用户可用。
JSON 返回值只是返回一个匿名类型,单个属性为result。我们将在视图中的 jQuery 代码中查找这个值为ok的属性。
图书馆视图
的视图我们的图书馆几乎是RecipeController的Index视图的直接副本。它在用户的库中显示食谱列表。它有一个额外的列,以前在食谱列表中是不可用的。此栏允许用户使用ActionLink超文本标记语言助手从他们的库中删除配方:
@Html.ActionLink(
"Remove from Library",
"Delete",
"Library",
new { id = item.RecipeId },
new {
@class = "remove-recipe",
data_id = @item.RecipeId
})
这个动作链接没有什么特别的。如果用户点击它,他们会收到一个 404 错误。LibraryController内没有响应 HTTP GET 的删除动作。但是,有一个响应 POST 的链接,我们的视图使用 jQuery 将该链接转换为 AJAX POST:
$(".remove-recipe").click(function(event) {
event.preventDefault();
$link = $(this);
$.post($link.attr('href'),
{ __RequestVerificationToken: $("input[name=__RequestVerificationToken]").val() },
function (response) {
if (response.result == "ok") {
$link.closest('tr').remove();
}
}
);
});
关于 jQuery 的完整讨论超出了本书的范围,但是对代码的简要概述如下。jQuery 使用 jQuery 选择器将自己附加到页面上的所有元素,CSS 类为remove-recipe。这是当前分配给我们页面上所有移除链接的类。当用户点击链接时,通过调用preventDefault来阻止导航事件。取而代之的是,jQuery 对链接的 HREF 属性中指定的 URL 执行 POST,并向其添加__RequestVerificationToken输入的值。这是AntiForgeryToken HTML 帮助器放置在页面上的输入:
@Html.AntiForgeryToken()
我们的控制器的Delete动作通过一个 HTTP POST 调用,并按照动作上的ValidateAntiForgeryToken属性的要求接收防伪令牌。它从用户的库中移除配方并返回ok。jQuery 代码接收响应并检查ok。收到后,该行将从页面中删除。
类似的操作已经应用到RecipeController的Index视图,允许用户将食谱添加到他们的库中。此链接和我们应用主导航中的Library链接仅提供给经过身份验证的用户。LibraryController的Index视图如下图所示:

配方库现已完成。
总结
在本章中,我们学习了身份验证和授权。我们学习了如何创建用户,以及如何允许具有外部身份验证的用户注册我们的网站。我们还学习了如何保护我们应用的某些部分,以便只有经过身份验证的用户才能进行更改。
这一章以食谱库的创建而结束。这个图书馆代表了迄今为止我们收集的知识的总和。它也代表了我们应用功能需求的完成。在接下来的几章中,我们将从异步动作和捆绑包的讨论开始,研究提高应用性能和用户体验的方法。这本书的最后几章将集中讨论如何利用我们功能齐全的 MVC 4 应用,为我们的用户创造真正独特的移动体验。
十、异步编程和捆绑包
在满足了我们对功能的最低要求后,我们的应用已经发布,并在互联网上提供。在我们有机会眨眼之前,BrewHow.com 正以我们几乎无法想象的速度被用户淹没。几周内,热门科技新闻媒体都在谈论我们将成为史上最大的 IPO。当我们突然醒来时,我们开始想我们将如何处理我们新发现的财富。
很难过这只是一个梦,我们开始想,如果我们有幸遇到一些最近的技术宠儿遇到的那种规模问题,我们会怎么做。我们决定需要了解更多关于构建响应性应用的知识。
在这一章中,我们将探讨如何通过提高服务器端的性能来使我们的应用更好地响应用户。这些改进将集中在我们的应用如何更有效地向用户提供信息,并减少等待时间。为此,我们将分别探讨异步编程和捆绑包。
异步编程
异步编程是一种我们可以用来执行计算量大或输入/输出量大的任务的技术,这些任务通常会阻塞我们的应用,例如请求网络服务或执行昂贵的文件输入/输出。这些任务可以被发送到独立的线程,直到它们完成。当这些昂贵的操作在后台执行时,应用可以着手满足用户的需求。
大多数框架都提供了一种机制,要么等待已经排队等待后台执行的某个任务完成,要么提供一个回调,以便在后台任务完成时调用。那个.NET 框架也没有什么不同,在 4.0 版本中,异步编程随着任务并行库 ( 第三方语言)的引入而大大简化。
任务并行库
第三方物流为中的低级线程机制提供了一个抽象.NET 框架,用于提供并行性和并发性。在System.Threading和System.Threading.Tasks中定义,这个库允许我们关注如何将我们的应用分解成异步任务,并将线程调度、分区和取消的担忧留给框架。
我们在应用中提供异步功能的大部分工作将涉及到System.Threading.Tasks中定义的第三方物流Task类的使用。
任务
Task类用于封装一个要异步执行或与其他任务并行执行的工作单元。如果我们认为工作需要很长时间才能完成,需要大量的输入/输出,或者会在不可接受的时间内阻止用户与我们的应用交互的执行,我们可能会选择在Task类中执行工作。
为了创建和管理任务,Task类为我们提供了一个流畅的应用编程接口,通过它我们可以创建、调度、执行和取消任务。
创建任务
虽然有几种方法可以在第三方物流中创建和执行Task类,但最常用的方法是通过Task类的静态Factory属性:
var longTask = Task
.Factory
.StartNew(() => DoLongRunningTask());
上面的代码创建并启动了一个新的Task,在DoLongRunningTask方法中执行代码。在幕后,运行时创建一个新的线程,在其上执行DoLongRunningTask方法。正如所写的,这段代码没有提供任何方法让任务通知我们它的完成。
这种一劳永逸的方法可以用在需要执行后台任务但不太关心任务何时完成或任务的返回值是什么的应用中。
然而,我们正在编写一个移动网络应用,在一个运行在 IIS 启动线程上的网络应用中,不建议希望它们成功完成。事实上,如果 web 应用中发生的任何未处理的异常都与活动请求无关,那么它将导致进程停止。因为我们可能不想这样做,所以我们需要等待在请求的上下文中开始的任何任务的完成。
等待完成
让我们通过检查以下代码来开始了解如何等待任务的完成:
Task<bool> ingredientCheck = Task
.Factory
.StartNew(() =>
{
return IsIngredientInStock(ingredient);
});
这段代码构建了一个新的任务,在后台执行IsIngredientInStock方法,然后返回结果。这个假设的方法出去检查我们最喜欢的酿酒供应店的成分。如果我们假设IsIngredientInStock方法的返回类型为bool,则分配给我们的新任务的ingredientCheck变量的类型为Task<bool>。返回类型实际上是从被调用的通用StartNew方法中推断出来的。
为了等待分配给我们的ingredientCheck变量的任务完成,我们简单地称其为Wait方法:
ingredientCheck.Wait();
var taskResult = ingredientCheck.Result;
该方法的返回值可通过ingredientCheck的Result属性获得。由于ingredientCheck变量为Task<bool>,因此Result属性为bool类型。
如果我们想检查多个商店,而不是检查我们最喜欢的自制商店的一种成分,会怎么样?第三方物流为我们提供了使用Task类的静态WaitAll方法等待多个任务完成的能力。
var task1 = Task
.Factory
.StartNew(() => { return CheckStore1(ingredient); });
var task2 = Task
.Factory
.StartNew(() => { return CheckStore2(ingredient); });
Task.WaitAll(new Task[] { task1, task2 });
// Nothing below this will execute until both tasks complete.
对Task静态WaitAll方法的调用被阻塞。在task1和task2完成之前,它不会让任何进一步的代码在当前线程上执行。
等待*方法,如Wait、WaitAll和WaitAny被提供来允许我们让出执行,直到给定的任务或任务组已经完成。这些方法顾名思义就是这样做的;他们会等到一个任务或一组任务完成后再继续。
如果我们的应用的需求要求我们不要简单地等待任务的完成,而是希望在任务完成时得到通知,我们可以向任务注册一个回调。
完成回调
回调在第三方物流中通过Task类的ContinueWith方法得到的支持。ContinueWith方法在Task类中定义如下:
public Task ContinueWith(Action<Task> continuationAction)
作为一个参数,ContinueWith方法期望一个Action,等待完成的Task类被作为一个参数传递给它。在前面的Task完成之前,ContinueWith的Action参数内的代码不会执行。除了处理任务完成,我们还可以使用这个功能来链接Tasks。
举例来说,如果我们只想在DoLongRunningTask方法完成时向控制台记录一条消息,我们可以编写如下代码:
var longTask = Task
.Factory
.StartNew(() => DoLongRunningTask())
.ContinueWith((previousTask) =>
{
Console.WriteLine("Long Running Task Completed.");
}
在DoLongRunningTask完成之前,ContinueWith方法中的代码不会被调用。
如您所见,第三方语言和我们使用它的流畅应用编程接口允许我们以更符合我们线性思维的方式编写多线程代码。它的介绍在。与涉及使用AsyncCallback、IAsyncResult等的传统异步编程方法相比,NET 4.0 是一个巨大的进步。
英寸 NET 4.5 中,框架引入了两个新的 C#关键词来进一步简化异步编程:async和await。
异步
async 关键词是修饰词,就像static或const一样。它用于将方法声明标记为异步。它的使用有几个限制。标有async修饰符的方法必须具有Task、Task<TResult>或void的返回类型,并且该方法不得采用任何ref或out参数。
要声明一个没有返回值的async方法,需要声明该方法的类型为Task:
async Task JustDoIt()
{
// Do your thing
}
如果定义为async的方法需要返回值,则必须声明为Task<TResult>,其中TResult标识Task返回值的类型:
async Task<TResult> ReturnSomethingLater()
{
TResult returnVal;
// Do some operation.
return returnVal;
}
当异步方法用于需要void类型的事件处理程序时,我们可以将异步方法声明为void类型:
async void ImRarelyUsed()
{
// Do my job.
}
这些void方法的行为不同于那些返回Task或Task<TResult>的方法,不应该用于我们在这里介绍的常见异步任务。
正如你所看到的,使用async修改器有很多限制。还有一个限制值得一提。标有async修饰符的方法必须包含await运算符。
等待
await 运算符通常被视为async修饰符的伴词。它用于识别async方法中运行时暂停处理直到异步任务完成的点:
var asyncTask = DoBackgroundWork();
// Do some additional work while
// the background task completes.
await asyncTask;
在前面的示例代码中,方法DoBackgroundWork返回Task或Task<TResult>。方法被调用后,调用者在后台任务执行的同时着手完成其他工作。只有当await操作符被调用时,调用者才等待异步任务的完成。
Await其实有点用词不当。await的来电者只是被暂停;当前线程没有被阻塞。相反,await操作符之外的代码的剩余部分被注册为当前线程的延续,线程可以自由地进行其他工作。当与await操作符相关联的任务已经完成时,在本例中为DoBackgroundWork,它调用暂停线程的继续和超出await关键字的任何代码。
如果标记有async修饰符的方法中不存在await运算符,该方法将被视为同步的,编译器将生成警告。
异步编程是一个通常与桌面或后端服务器应用相关的话题。这是不幸的,因为我们可以使用新的async和await关键词将异步编程概念应用到我们的移动网络应用中。
异步控制器动作方法
在我们深入讨论异步控制器动作之前,了解框架和 IIS 如何处理传入控制器的请求可能会有所帮助。
当 IIS 收到对资源的请求时,它会将该请求交给该请求的处理程序——对于 ASP.NET MVC,该请求会交给 ASP.NET。ASP.NET 依次将请求传递给.NET 框架,专门处理网络请求。这个过程让 IIS 可以自由处理入站请求,因为它只是将请求委托给适当的处理程序。由于大多数 web 应用的请求处理只需要几秒钟就可以在框架内执行,所以这个线程切换过程几乎可以保证 IIS 能够处理所有入站 web 请求。
但是,如果大多数 web 请求最终都是长时间运行的请求、调用 web 服务的请求、对文件系统执行操作的请求等等,那么.NET 框架会变得疲惫不堪。发生这种情况时,IIS 将开始对请求进行排队。如果请求队列已满,IIS 将开始向客户端返回 HTTP 状态代码 503,表示服务器正忙。
异步控制器动作提供了将工作从请求线程卸载到工作线程的能力。当一个请求进入 web 服务器时,服务器从线程池中获取一个线程。线程池在看到可以执行异步操作时,调度异步操作,然后返回线程池。异步操作完成后,web 服务器会收到通知,并从池中检索另一个线程来完成请求并向客户端返回响应。
异步动作的完成时间与同步动作方法一样长。当我们需要完成可能是 CPU、网络或 I/O 绑定的操作(通常是响应 web 请求的长时间运行的操作)时,它们只是允许我们释放资源来处理额外的请求处理。
创建异步动作
在 ASP.NET MVC 4 之前,异步动作方法只在扩展AsyncController基类的控制器中支持。每个异步动作都遵循基于事件的异步模式,其中初始方法有一个Async后缀,完成方法有一个Completed后缀:
public class HomeController :AsyncController
{
public void IndexAsync()
{
AsyncManager.OutstandingOperations.Increment();
Task.Factory.StartNew(() => ExecuteAsyncTask());
}
private void ExecuteAsyncTask()
{
// Do asynchronous work.
AsyncManager.OutstandingOperations.Decrement();
}
public ActionResult IndexCompleted()
{
return View();
}
}
随着async修饰符的引入.NET 4.5,异步动作的创建得到了极大的简化:
public class HomeController : Controller
{
public async Task<ActionResult> IndexAsync()
{
var asyncTask = Task
.Factory
.StartNew(DoBackgroundWork());
// Do some work.
await asyncTask();
}
}
事实上,异步动作的声明与用async修饰符标记的任何其他异步方法的声明没有什么不同。
注
Async后缀的使用不是强制性的,对运行时没有影响。然而,这是一个建议遵循的惯例。
异步配方控制器
让我们假设我们的应用给网络服务器带来了过度的压力,因为配方控制器花费了大部分时间从数据库中检索对象,并将它们转换为视图模型。这种压力导致我们的网络服务器向我们应用的一些用户返回 HTTP 状态代码 503。我们已经查看了我们的模式和数据库,并确定我们的模式已经被适当地索引和维护。我们没有建立网络农场的预算或资源。但是,我们知道,如果 web 服务器没有等待我们处理这些请求,我们的用户就不会收到这些断断续续的 503 条消息。看来是时候让RecipeController类常用的动作异步了。
以下是检索RecipeController的Index动作的异步动作:
public async Task<ActionResult> Index(int page = 0)
{
var recipeListTask = Task.Factory.StartNew(() =>
{
var recipes = _recipeRepository.GetRecipes();
var viewModel = new PagedResult<RecipeEntity, RecipeDisplayViewModel>(
recipes,
page,
this._displayViewModelMapper.EntityToViewModel);
return viewModel;
});
return View(await recipeListTask);
}
类型
HttpContext 为空
在处理异步任务时,有一条非常重要的信息需要记住。当前HttpContext没有分配给异步任务正在执行的线程。如果您需要访问它,您将需要找到一种方法将它或您需要的数据传递到任务中。这方面的一个例子可以在本书附带的代码中的LibraryController中找到。
在优化了RecipeController内的操作后,下一个选择是减少对我们的网络服务器的入站请求数量,但不减少访问者的数量,请注意,并减少网络服务器向我们的客户端发回响应的时间。我们可以使用捆绑包来做到这一点。
捆
在 ASP.NET MVC 4 中,术语包指的是一个或多个文件,通常是 JavaScript 或 CSS,在运行时注册为一个组。
当请求捆绑包时,组成捆绑包的文件在称为捆绑的过程中相互附加。捆绑的文件然后经历一个缩小过程,在这个过程中,文件被去掉注释和空白,局部变量的名称,如果有的话,被缩短。
通过使用捆绑包,客户端可以减少它必须向服务器发出的检索内容的请求的数量和大小。当应用于移动应用开发领域时,捆绑会对应用的感知性能产生重大影响。
类型
CDN 支持
虽然本章没有讨论,但是捆绑包也支持使用常见的内容交付网络,如微软和谷歌提供的网络。
创建包
我们的移动应用目前包含六个注册捆绑包。这些包在我们项目的AppStart文件夹中包含的BundleConfig类中注册。
public class BundleConfig
{
public static void RegisterBundles(
BundleCollection bundles)
{
bundles.Add(new ScriptBundle("~/bundles/jquery")
Include("~/Scripts/jquery-{version}.js"));
// Code removed for brevity.bundles.Add(new ScriptBundle("~/bundles/modernizr").Include("~/Scripts/modernizr-*"));
bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css"));
// More code removed for brevity...
}
}
当应用启动时,从Global.asax.cs中调用RegisterBundles方法来注册我们的包定义。包定义包含包类型和要包含在包中的文件。捆绑包类型本身标识了可以请求捆绑包的位置。
注
虽然为了简洁起见,上面的代码中省略了三个包,但显示的三个包不是随机选择的。它们说明了不同捆绑包类型的使用以及在定义捆绑包时对通配符的支持。
束类型
在 RegisterBundles方法中,两种不同类型的包在运行时注册:ScriptBundle s 和 T2。这些包包含您可能期望的内容:脚本(特别是 JavaScript)和样式(特别是 CSS)。
虽然这是仅有的两种开箱即用的支持捆绑包,但微软意识到您可能希望支持以其他语言编写的其他类型文件的捆绑包。为了支持这一点,框架有一个基本的Bundle类,您可以使用它来添加对其他类型的支持。
添加对新捆绑类型的支持不是一件小事,因为您必须编写自己的自定义转换来实现IBundleTransform接口。StyleBundle和ScriptBundle在引擎盖下使用一个叫做WebGrease的工具来执行的 JavaScript 和 CSS 的转换。通过 NuGet 提供的其他包支持 LESS 和 CoffeeScript 等语言。在你跑去写你自己的之前,我建议检查一下你的语言是否已经被支持了。
通配符支持
如果我们查看以下包定义,我们会注意到它们使用通配符机制将文件包含在包中:
bundles.Add(new ScriptBundle("~/bundles/jquery").Include("~/Scripts/jquery-{version}.js"));
bundles.Add(new ScriptBundle("~/bundles/modernizr").Include("~/Scripts/modernizr-*"));
对通配符的支持允许我们将相关文件包含在一起,而不必在包中的Include中枚举每个文件。这些包本身足够聪明,可以在发生冲突时对要包含的文件做出一些决定。如果您恰好有一个缩小版本的 jQuery、Visual Studio 调试版本的 jQuery 和标准版本的 jQuery 位于一个目录中,那么如果您的目标是发布版本,包中支持的通配符规则将包括文件的缩小版本,如果您不是,则包括标准的 jQuery 版本。
在通配符支持的土地上,一切都不是彩虹和独角兽。通配符包含将按字母顺序向包中添加文件。如果您不幸地以字母顺序不同于依赖顺序的方式编写脚本,您将需要创建多个包,创建一个实现IBundleOrderer接口的类来支持您的排序,或者使用Include方法按照文件必须执行的顺序将文件包含在包中。
应谨慎使用通配符支持。
消费捆绑包
现在我们知道了什么是捆绑包以及它们是如何创建的,让我们快速了解一下如何消费它们。
如果你打开布局页面,_Layout.cshtml,你会在顶部看到两行:
@Styles.Render("~/Content/css")
@Scripts.Render("~/bundles/modernizr")
这些行分别查询运行时的位置为~/Content/css和~/bundles/modernizr的样式包和脚本包。底部还有一行包含 jQuery 包的代码:
@Scripts.Render("~/bundles/jquery")
启动网站查看菜谱列表的源代码,我们可以看到顶部的两个包翻译成其中包含的实际文件:

如果我们在浏览器中查看styles.css和modernizer.js文件的内容,我们会发现它们与存储在磁盘上的版本完全相同。那么会有什么结果呢?使用捆绑包不是应该合并和缩小每个定义吗?答案是视情况而定。
捆绑过程的智能不仅限于通配符文件包含。如果我们检查web.config文件,我们会看到我们的应用目前是在调试模式下编译的。
<compilation debug="true" targetFramework="4.5" />
如果在站点当前处于调试模式时正在处理包,包过程只需将指向所有当前形式文件的链接添加到页面中。这其实是一种期望的行为;你不想尝试和调试迷你化的 JavaScript 或 CSS。
将debug属性设置为false将启用包过程,并将更改页面的输出,以实际引用包,而不是单个文件。
注
通过将BundleTable类的EnableOptimizations属性设置为true,也可以打开捆绑包的捆绑和缩小行为。如果这样做了,在web.config中将compilation节点的debug属性设置为true将不再对该过程产生影响,因为EnableOptimizations属性取代了任何其他配置。

您应该注意到源中每个捆绑链接后面的查询字符串。这些查询字符串是一个哈希,用于表示包的内容,并有助于打破浏览器中的缓存,该浏览器的实现保存脚本和 css 文件的时间比请求的时间长。当包中任何文件的内容发生变化时,会生成一个新的哈希,从而产生一个新的查询字符串,并因此产生一个新的链接。当浏览器遇到新链接时,它将无法在其缓存中找到该文件,并将再次从服务器检索该文件。
对文件内容的检查表明内容确实被缩小了。

这看起来不是一个大的变化,但是性能的提高是相当可观的,尤其是对于那些围绕着智能客户端设计的、脚本编写量很大的网站。即使在我们运行在 localhost 上的小应用上,性能也有了显著的提高。
打开 Chrome 的开发者工具窗口中的网络标签,我们可以看到我们的网站从本地主机加载花费了 405 毫秒,完成了 307 千字节的数据传输,没有缩小。

如果我们查看网站发布版本的完全相同的请求,我们会看到加载时间下降了到 322 毫秒。这不是一个巨大的下降,但是我们是从 localhost 加载的,所以我们不应该期望看到这么小的网站有大的数字。
更大的区别在于我们从服务器发送到客户端的数据量。我们从 307 千字节下降到 52.5 千字节,实际上数据量下降到原来的 1/6。

总结
在本章中,我们学习了多种方法来提高我们的应用的性能,从而改善我们的服务器和用户。我们学习了如何通过使用第三方语言同时执行多个任务,以及如何在异步控制器操作中执行长时间运行的过程时释放 IIS 来处理请求。
我们还了解了捆绑包,以及我们如何使用它们来缩小我们网站在带宽和请求聊天方面的占用空间。
在下一章中,我们将重点关注用户体验,探索 SignalR 以及它如何使我们能够与用户提供实时交互和反馈。
十一、实时网络编程
随着网络应用和传统桌面应用之间的界限变得模糊,我们的用户已经开始期待我们的网络应用中的实时行为,这是传统上桌面的领域。人们真的不能责怪他们。与数据、服务甚至其他用户的实时交互推动了互联革命,我们现在的互联方式比以往任何时候都多。无论这种时刻保持联系并立即获知事件的愿望多么合理,网络应用中的实时交互都存在固有的挑战。
第一个挑战是网络是无状态的。网络是建立在 HTTP 之上的,HTTP 是一种请求/响应协议;对于浏览器发出的每个请求,都有且只有一个响应。我们可以使用一些框架和技术来掩盖网络的无状态性,但是网络或 HTTP 中并没有真正的状态。
这就更加复杂了,因为网络是客户机/服务器。由于它是无状态的,服务器只知道在任何给定时刻连接的客户端,客户端只能根据与服务器的最后一次交互向用户显示数据。客户端和服务器唯一了解对方的时间是在活动请求/响应期间,此操作可能会改变客户端或服务器的状态。对服务器状态的任何更改都不会反映给其他客户端,直到它们用新请求连接到服务器。这有点像不确定性原理,因为一个人越是试图确定关系中的一个数据点,另一个人就变得越不确定。
所有的希望都没有失去。有几种技术可以用来实现 web 服务器和任何活动客户端之间的实时(或接近实时)数据交换。
模拟连接状态
在传统的网络开发中,还没有一种方法来保持客户端浏览器和网络服务器之间的持久连接。Web 开发人员已经竭尽全力,试图在 HTTP 的请求/响应世界中模拟一个互联的世界。
一些开发人员成功地利用创造性思维和标准本身的漏洞来开发诸如长轮询和永久框架等技术。现在,由于意识到需要这样一种技术,监督下一代网络标准的组织也开始关注服务器发送的事件和网络套接字。
长轮询
长轮询 是任何客户端和服务器内容交换的默认后备。除了 HTTP,它不依赖任何东西——不需要特殊的标准清单或其他狡辩。
长时间的投票就像得到伴侣的沉默对待。你问一个问题,无限期地等待答案。经过一段已知的时间和看似永恒的时间后,你终于得到了一个答案,或者请求最终超时了。这个过程一次又一次地重复,直到请求被完全满足或者关系终止。所以,是啊,这就像沉默治疗。
永远的框架
永远框架技术 依赖于 HTTP 1.1 标准和一个隐藏的iframe。当页面加载时,它包含(或构建)一个隐藏的iframe,用于向服务器发出请求。客户机和服务器之间的实际交换利用了 HTTP 1.1 的一个称为 分块编码的特性。分块编码由 HTTP Transfer-Encoding头中的值chunked标识。
这种数据传输方法旨在允许服务器在内容的整个长度已知之前开始向客户端发送部分数据。当模拟浏览器和网络服务器之间的实时连接时,服务器可以根据iframe的请求将消息作为单独的块发送给客户端。
服务器发送的事件
服务器发送事件 ( 上交所 ) 为服务器提供了在客户端网页浏览器中引发 DOM 事件的机制。这意味着要使用 SSE,浏览器必须支持它。在撰写本文时,对 SSE 的支持很少,但它已经提交给 W3C,以纳入 HTML5 规范。
SSE 的使用从声明一个EventSource变量开始:
var source = new EventSource('/my-data-source');
如果您想监听任何和所有由源发送的消息,您只需将其视为一个 DOM 事件,并用 JavaScript 处理它。
source.onmessage = function(event) {
// Process the event.
}
上交所支持特定事件和复杂事件消息的提出。消息格式是 JSON 的简单的基于文本的格式衍生。两个换行符分隔流中的每个消息,每个消息可能有一个id、data和event属性。上交所还支持在消息中使用retry关键字设置重试时间。
:comment
:simple message
data:"this string is my message"
:complex message targeting an event
event:thatjusthappened
data:{ "who":"Professor Plum", "where":"Library", "with":"candlestick" }
截至本文撰写之时,Internet Explorer 中不支持 SSE,并且在少数移动浏览器中部分实现了 SSE。
网络套接字
网络上实时通信的妙招是网络套接字。网络套接字支持网络浏览器和网络服务器之间的双向流,并且仅利用 HTTP 1.1 来请求连接升级。
一旦连接升级被批准,网络套接字通过 TCP 连接使用网络套接字协议进行全双工通信,实际上是在浏览器中创建一个可用于实时消息传递的客户端-服务器连接。
所有主要的桌面浏览器和几乎所有的移动浏览器都支持网络套接字。但是,网络套接字的使用需要网络服务器的支持,并且网络套接字连接在代理后面可能无法成功工作。
有了所有可用的工具和技术来实现我们的移动网络应用和网络服务器之间的实时连接,人们如何做出选择?我们可以编写代码来支持长轮询,但这显然会耗尽服务器上的资源,并要求我们在终端做一些相当广泛的管道工作。我们可以尝试使用网络套接字,但是对于缺乏支持的浏览器或代理背后的用户,我们可能会引入比我们能解决的更多的问题。如果有一个框架来为我们处理所有这些,请尝试可用的最佳选项,并在需要时降低到几乎保证的长轮询功能。
等等。有。叫做信号员。
信号
signor 提供了一个框架,将之前提到的所有实时连接选项抽象为一个内聚的通信平台,支持 web 开发和传统桌面开发。
在客户端和服务器之间建立连接时,信号员将根据客户端和服务器的能力协商最佳连接技术。实际使用的传输隐藏在更高级别的通信框架之下,该框架公开了服务器上的端点,并允许客户端调用这些端点。反过来,客户端可以向服务器注册,并向其推送消息。
每个客户端通过一个连接标识对服务器进行唯一标识。此连接标识可用于向客户端或远离客户端发送消息。此外,SignalR 支持组的概念,每个组都是连接标识的集合。这些组,就像单独的连接一样,可以被明确地包括在通信交换中或从通信交换中排除。
SignalR 中的所有这些功能都是由两个客户端/服务器通信机制提供给我们的:持久连接和集线器。
持久连接
持久连接是 SignalR 的低级连接。这并不是说它们提供了对 SignalR 正在使用的实际通信技术的访问,而是为了说明它们作为客户端和服务器之间原始通信的主要用途。
持久连接的行为很像传统网络应用开发中的套接字。它们在较低层次的通信机制和协议之上提供了一个抽象,但提供的并不多。
当创建一个端点来处理 HTTP 上的持久连接请求时,处理连接请求的类必须位于Controllers文件夹(或任何其他包含控制器的文件夹)中,并扩展PersistentConnection类。
public class MyPersistentConnection: PersistentConnection
{
}
PersistentConnection类通过事件管理从客户端到服务器的连接。为了处理这些连接事件,任何从PersistentConnection派生的类都可以覆盖在PersistentConnection类中定义的方法。
客户端与服务器的交互会引发以下事件:
OnConnected:当与服务器建立新的连接时,框架会调用这个函数。OnReconnected:当已经终止的客户端连接重新建立了与服务器的连接时,会调用该选项。OnRejoiningGroups:当重新建立超时的客户端连接时调用此选项,以便该连接可以重新加入适当的组。OnReceived:从客户端接收数据时,调用此方法。OnDisconnected:当客户端和服务器之间的连接已经终止时调用。
通过PersistentConnection类的Connection属性与客户端进行交互。当事件被引发时,实现类可以确定它是否希望使用Connection.Broadcast广播消息,使用Connection.Send响应特定客户端,或者使用Connection.Groups将触发消息的客户端添加到组中。
枢纽
集线器通过屏蔽管理客户端和服务器之间的原始连接所涉及的一些开销,为我们提供了对PersistentConnection类的抽象。
类似于持久连接,集线器包含在项目的Controllers文件夹中,但是扩展了Hub基类。
public class MyHub : Hub
{
}
虽然集线器支持连接、重新连接和断开事件的通知能力,但与事件驱动的持久连接不同,集线器为我们处理事件调度。Hub类上任何公开可用的方法都被视为端点,任何客户端都可以通过名称寻址。
public class MyHub : Hub
{
public void SendMeAMessage(string message)
{ /* ... */ }
}
集线器可以使用Hub基类的Clients属性与其任何客户端通信。这个属性支持方法,就像PersistentConnection的Connection属性一样,可以和特定的客户端、所有客户端或者客户端组进行通信。
我们将从一个例子中学习,而不是分解Hub类中所有可用的功能。
实时配方更新
在我们的酿酒之路移动应用中,当我们查看食谱列表时,它会很高兴收到新食谱添加的通知。为了实现这一点,我们将使用 SignalR 框架提供的Hub机制来实现对酿酒方法集合的添加的实时通知。
安装和配置信号装置
信号员,像一样最现代.NET 框架,作为一个 NuGet 包提供:Microsoft.AspNet.SignalR。我们可以通过在软件包管理器控制台中输入以下内容来安装软件包:
Install-Package Microsoft.AspNet.SignalR
除了对我们项目的几个程序集引用之外,SignalR 包还添加了一个新的 JavaScript 文件:jquery.signalR-1.1.2.min.js—您的版本可能会有所不同,这取决于您实际阅读该文件的时间。这个 JavaScript 文件包含了客户端网络浏览器与两种类型的信号端点通信所需的所有抽象:持久连接和集线器。
SignalR JavaScript 文件只是客户端难题的一部分。为了在我们的应用中启用 Signar 支持,我们需要添加对 SignalR JavaScript 库的引用,并调用处理程序/signalr/hubs,该处理程序用于为我们项目中的任何中枢创建 JavaScript 代理。这些参考将放在_Layout.cshtml中。
@Scripts.Render("~/bundles/jquery")
<script
src="~/Scripts/jquery.signalR-1.1.2.min.js"
type="text/javascript"></script>
<script
src="~/signalr/hubs"
type="text/javascript"></script>
@RenderSection("scripts", required: false)
我们还必须向运行时注册/signalr/hubs路由。我们可以通过简单地调用路由集合的MapHubs扩展方法来做到这一点,在这里我们为我们的应用注册其他路由。
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapHubs();
routes.MapRoute(
name: "BeerByStyle",
请注意,中枢路由位于所有其他MapRoute呼叫或我们可能用于注册路由的其他方法之前。我们这样做是因为路由选择是在第一次匹配时进行的,我们不想无意中在路由表中的 SignalR 之前注册一些东西。
创建配方中心
我们需要提供一个中枢,客户端可以连接到该中枢来接收关于新配方添加的通知。
右键点击我们项目的Controllers文件夹,选择添加 | 新项目……。

在添加新项目对话框中,搜索信号员并选择信号员中枢类。命名类RecipeHub.cs点击添加。

我们需要修改 Visual Studio 生成的RecipeHub类。如前所述,客户端将接收关于新食谱的通知,但是没有客户端会直接发布到这个中心与服务器通信。因此,我们只需要创建一个空的中心。
namespace BrewHow.Controllers
{
public class RecipeHub : Hub
{
}
}
乍一看,一个空类可能看起来毫无意义,但是如果没有类声明,SignalR 无法为客户端创建一个代理来与服务器交互。
修改配方列表视图
配方列表视图需要修改才能连接到配方中心。业务的第一个顺序是为配方列表提供一个标识。该标识将用于使用 jQuery 定位和修改列表。给配方列表分配一个recipe-list的标识。
<table id ="recipe-list">
我们现在可以添加一些连接到配方中心的 JavaScript,并在收到新配方的通知后,在表格底部添加背景颜色为黄色的新配方。
$(function () {
$.connection.hub.start();
var recipeHub = $.connection.recipeHub;
recipeHub.client.recipeAdded = function (recipe) {
var tr = $("#recipe-list").find('tbody')
.append(
$('<tr>').css('background-color', '#ff0')
.append($('<td>')
.append($('<a>')
.attr('href', '/Recipe/Details/'
+ recipe.RecipeId
+ "/" + recipe.Slug)
.text(recipe.Name))
)
.append($('<td>')
.append($('<a>')
.attr('href', '/Recipe/'
+recipe.StyleSlug)
.text(recipe.Style))
)
.append($('<td>'
+ recipe
.OriginalGravity
.toFixed(3)
+ '</td>'))
.append($('<td>'
+ recipe
.FinalGravity
.toFixed(3)
+ '</td>'))
@if (Request.IsAuthenticated) {
@: .append($('<td>'
@: + 'Add to Library'
@: + '</td>'))
}
);
}
});
JavaScript 代码包含在闭包中,确保它只被调用一次,不能被任何外部源调用。第一行代码在客户端启动集线器连接:
$.connection.hub.start();
connection对象是由 SignalR JavaScript 添加到 jQuery 的对象。连接对象的hub属性提供了对 SignalR 客户端库的中枢基础结构的引用。对start方法的调用初始化了 SignalR 客户端,并准备了由我们的_Layout.cshtml页面中的/signalr/hubs调用生成的代理代码,以接收来自服务器的通知。
接下来,JavaScript 建立了与我们的配方中心的连接:
var recipeHub = $.connection.recipeHub;
仔细检查这个代码,你会发现到我们RecipeHub类的连接在连接类中被标识为recipeHub。为我们的应用中的集线器生成代理类的/signalr/hubs调用使用集线器类名的驼色版本将它找到的每个集线器添加到connection对象中:RecipeHub变成recipeHub、MyHub变成myHub等等。
下一行代码在客户机上注册了一个方法,当添加新配方时,服务器将调用该方法。
recipeHub.client.recipeAdded = function (recipe) {
我们可以随心所欲地调用这个方法——除了与中心服务器端方法匹配的名称。正是在客户机上声明一个函数并将其分配给集线器的client属性的行为使得服务器可以使用该方法。
代码的其余部分只是获取它接收到的RecipeDisplayViewModel对象,并用黄色高亮显示将其附加到表格中。
发布事件通知
我们已经讨论了在基于Hub或PersistentConnection的课程中回应客户。然而,我们的RecipeHub班是空的,我们没有其他的枢纽。别担心。在配方保存到存储库中后,我们可以通过将代码放入RecipeController类的Create方法来通知我们应用的其他用户发生了此事件。
var context = Microsoft.AspNet.SignalR.
GlobalHost
.ConnectionManager
.GetHubContext<RecipeHub>();
context
.Clients
.All
.recipeAdded(
_displayViewModelMapper
.EntityToViewModel(recipeEntity)
);
这段代码从检索我们的RecipeHub类的上下文开始。我们使用信号员ConnectionManager的GetHubContext<T>()方法来完成。然后,我们准备使用上下文的Clients.All属性向RecipeHub类的所有客户端进行广播。
阿瑟·克拉克爵士说:
“任何足够先进的技术都无法与魔法区分开来。”
我会让你来判断它是否有魔力,但是要调用我们在 JavaScript 中定义并分配给client的recipeAdded方法,我们只需在这里调用它,传递我们希望返回的数据。运行时为我们处理事件调度,并通知RecipeHub类的所有客户端我们正在调用recipeAdded方法。如果客户端上有这样的方法,它将由 SignalR 客户端代码调用。
要使这项工作成功,还需要再做一次更改。当recipeEntity类被创建时,我们的存储库当前没有设置它的RecipeId属性。当我们使用配方的 ID 来提供列表中详细信息的链接时,我们需要确保广播发送到的所有客户端都可以使用它。这个变化相当简单。对实体框架上下文进行更改后,只需修改存储库以设置RecipeId。
recipeEntity.RecipeId = newRecipeModel.RecipeId;
现在一切都应该正常了。我们只需要同时连接两个客户端来测试它。

在谷歌浏览器中添加食谱后,它会神奇地出现在 Opera Mobile 的食谱列表中。

总结
在这一章中,我们看了信号。SignalR 框架为我们提供了对浏览器和网络服务器之间通信的前所未有的控制,实现了实时通信。这项技术可用于游戏、实时状态更新或模拟移动网络应用中的推送通信。
到目前为止,我们已经对 ASP.NET MVC 4 进行了广泛的研究,并提出了我们在构建移动网络应用时需要考虑的问题。在下一章中,我们将开始深入到移动 web,并查看最新版本的中可用的工具和技术.NET 框架。我们将从移动模板开始。
十二、为移动设备设计应用
我们已经在很短的时间内开发了一个相当完整的应用。我们的应用在桌面浏览器上运行良好,实际上在移动设备上相当有用(我们将很快详细说明这一点)。这在很大程度上是由于微软所做的工作以及他们提供的新应用模板。这些模板基于一套新的标准:HTML5 和 CSS3。
本章将重点介绍 HTML5 和 CSS3,大多数移动浏览器对这些新标准的支持,以及使用这些标准设计下一代移动 web 应用的能力。
HTML5
HTML5 是其前身的一个明显的转变。HTML5 不是关注标识数据结构的标记,而是专注于为内容提供意义的标记的语义语言。
除了这些语义标签之外,HTML5 还引入了对嵌入式视频和音频、浏览器本地数据库存储、绘图画布、表单控件、网络套接字以及足以填满整本书的其他功能——实际上有几个。有了新功能,HTML5 还减少了我们必须编写的标记量,以生成符合标准的文档。
HTML5 的内容肯定比我们在一章中所能涵盖的更多,所以我们现在将重点关注对我们的应用最重要的特性:标记更改、语义标签、自定义数据属性和新的表单控件。我们还将介绍 HTML5 提供的本地存储和地理定位服务,因为它们可以用来大大增强移动应用的体验。
标记更改
让我们通过查看一个传统的最低限度兼容的 XHTML 1.1 文档来开始对这些标记变化的检查。
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>BrewHow</title>
<link rel="stylesheet" href="styles.css" type="text/css">
</head>
<body>
</body>
</html>
马上,你就能看出这不是大多数人会用手打出的东西。HTML4 和 XHTML 1.1 给作者带来了很大的遵从负担。这是不幸的,因为解析器可以推断出合规性所涉及的标记的很大一部分。
在这方面,HTML5 旨在减少生成符合标准的文档所需的工作量。
文档类型标签
我们在每个超文本标记语言文档的开始看到的 DOCTYPE标签实际上并不是一个超文本标记语言标签。HTML 最初是与标准通用标记语言(SGML)一起开发的,这是一个旨在使用标记语言来描述标记语言的国际标准化组织标准。HTML4 基于 SGML,需要声明一个DOCTYPE。
DOCTYPE声明包含几条信息,它们共同组成了文件类型定义 ( DTD ) 。DTD 非常正式,严格定义了标题下的文档。由于 HTML4 是 SGML 的真正衍生,所以DOCTYPE头是一个必需的标记——缺点和全部。
由于 HTML5 不是基于 SGML 的,因此没有相关的开销,我们可以缩短声明。所以,在 HTML5 中:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
变成:
<!DOCTYPE html>
当浏览器看到这个最小的DOCTYPE声明时,它将尝试使用它拥有的最新引擎来解析和呈现文档。
注
需要注意的是,HTML5 不区分大小写。DOCTYPE、DocType、doctype、DoCtYpE待遇都一样。无论您选择使用哪种格式,只要确保您在整个文档中是一致的——为了您自己,而不是解析器。
字符集
XML 和 HTML 4.0 的默认字符集是 Unicode,特别是 ISO 10646。这不是必须强制执行的事情。相反,这个标准的存在是为了指示所有的 XML 解析器和网络浏览器,它们必须像在内部使用 Unicode 一样工作。当我们想和其他人玩得很好时,识别编写文档的字符集对我们和文档的消费者都有好处,这样客户就可以将我们的字符集映射到 Unicode。
在 HTML4 文档中,使用meta标记来指定字符集,以识别文档的Content-Type:
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
鉴于文档的类型在很大程度上是由DOCTYPE标签标识的,HTML5 为meta标签引入了一个新的charset属性,使得我们的字符集的声明更加简洁:
<meta charset="utf-8" />
类型属性
当声明要加载脚本、样式或其他外部媒体时,我们传统上必须通过type属性提供外部媒体的内容类型。对于 JavaScript,我们必须将类型标识为text/javascript。对于 CSS,我们必须提供一个内容类型text/css。
<link rel="stylesheet" href="styles.css" type="text/css">
假设浏览器可以推断出这些信息,那么在 HTML5 中type属性已经变成可选的,这样我们用来加载样式表的link标记现在可以写成如下形式:
<link rel="stylesheet" href="styles.css" />
类型
想了解更多?
在 HTML5 中有几个额外的属性修改,你可能不会每天遇到。例如,script标签的async属性可以指示浏览器加载并立即执行外部 JavaScript,而无需等待页面完成。我鼓励你研究 HTML5 标准,目前在http://www.w3.org/TR/html5/处于候选人推荐状态。
把它放在一起,我们的样板 HTML5 模板比本节开始时呈现的 HTML4 标记更清晰,更容易可视化解析。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BrewHow</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
</body>
</html>
Visual Studio 2012 支持
Visual Studio 2012 知道所有这些新的标记更改,并在我们的 BrewHow 应用使用的默认模板中为它们提供支持。
如果我们在项目中打开_Layout.cshtml文件,您会看到文件的前四行如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
似乎 Visual Studio 已经完成了简化标记的工作。让我们了解一下 HTML5 的语义标签,看看 Visual Studio 是否会像支持更简洁的标记一样为我们提供同样的支持。
语义标签
正如在本章介绍中提到的,语义标签允许我们将上下文应用到我们的内容中。使用一个简单的例子,可以通过使用div标签在 HTML4 中声明一个页脚。
<div id="footer"></div>
在 HTML5 中,footer是一个有效的标记,因此,我们的页脚声明现在为标记提供了意义。
<footer></footer>
HTML5 中的语义标签不是盲目选择的。相反,人们做了很多研究来查看网络上的内容,从这些内容中,最常用的类名和标识被编译并折叠成新的语义标签。
如果我们查看项目中已经存在的 HTML5 标记,可能会更容易,而不是提供 HTML5 中引入的所有标记的列表。我们的_Layout.cshtml模板已经为我们提供了几个 HTML5 语义标签,这些标签已经在下面的代码片段中突出显示:
<body>
<header>
<!-- snip -->
<section id="login">
@Html.Partial("_LoginPartial")
</section>
<nav>
<ul id="menu">
<li>@Html.ActionLink("Recipes", "Index", "Recipe")</li>
<!-- snip -->
</ul>
</nav>
</div>
</div>
</header>
<div id="body">
<!-- snip -->
</div>
<footer>
<!-- snip -->
</footer>
<!-- snip -->
</body>
看来,Visual Studio 又一次为我们简化了流程。让我们更详细地检查这些标签中的每一个,看看我们是否需要进行任何其他标记更改。
文章标签
article标签标识页面内的独立内容。你可以使用标签来识别一篇博客文章,或者,在我们的应用中,一个食谱。使用article标签时遵循的一般经验法则是询问article标签中的内容本身是否有意义。如果是的话,一个article标签是合适的。我们的模板中没有article标签,但它仍然是 HTML5 环境中的一个重要标签,我们将在本书的剩余部分中利用它。
标题标签
标签正是它的名字所暗示的。它用于识别分段内容的标题。在这种情况下,分段内容并不特定于下一节中讨论的section标签。相反,它意味着页面的任何逻辑分组,如节、文章或文档本身,都可以包含一个标题来标识其中包含的内容。
给定模板中的标题范围,我们可以确定这个标题指的是整个页面的内容。
截面标记
部分是一组内容,可以包含也可以不包含页眉和页脚。它用于将较大的内容分组分解成较小的块。
在_Layout.cshtml中的代码中,section标签用于标识特定于登录功能的标题部分和特定于页面一般内容的页面部分。
你也可以在一篇文章中加上section标签,把它分成几章。
导航标签
nav标签通常用于指示站点级别的导航。屏幕阅读器和其他浏览器可能实际上省略了这个标签的初始加载,以向用户提供对首先加载的页面的主要内容的访问。
我们的_Layout.cshtml文件包含一个nav部分,该部分包含配方列表、样式列表和用户库的链接。
页脚标记
正如标签描述中的所指出的,这些标签相互独立,不需要将任何标签嵌套在另一个标签中。我们可以将header和footer标签应用到一个article标签,一个section标签,或者像在我们的_Layout.cshtml文件中所做的那样,应用到整个文档。同样,物品可以包含儿童物品,并且每个物品可以被分成多个部分。语义标记背后的整个思想是为我们的内容提供意义,然后可能被其他人解释。
注
其他 HTML5 标签包括address、aside、date、figure、figcaption、hgroup、mark和time。关于 HTML5 标签的完整列表,我推荐http://www.w3.org/TR/html5/的 HTML5 候选推荐。
Visual Studio 中的新模板在使用新的 HTML5 标记方面做得非常好,因此我们应该只做一些修改。我们将使用配方详细信息视图来检查这些修改。
修改配方细节
在我们开始进行修改之前,让我们先来看一下食谱细节是如何在网络浏览器中呈现给用户的。

以下是标记中用于显示配方细节的部分:
<h2>Details</h2>
<fieldset>
<legend>Recipe Details</legend>
<!--Fields describing the recipe -->
</fieldset>
如果现有标记通过 HTML5 大纲视图运行,例如位于http://gsnedders.html5.org/outliner/的大纲视图,它将生成以下大纲:

为了使当前的标记有意义,我们可以改变一些事情。首先,有一个h2标签,用于将下面的信息标识为详细信息。这个似乎严重不足;它当然不是对内容的描述。其次,我们的食谱细节内容在我们网站的上下文之外是有意义的,这是一个值得标签的描述。
了解了如何更好地将标记应用于内容,我们可以将详细视图调整为如下所示:
<article id="recipe-detail">
<header>
<h2>Recipe for @Model.Name</h2>
</header>
<fieldset>
<legend>Recipe Details</legend>
<!--Fields describing the recipe -->
</fieldset>
</article>
对标记的这些更改对我们的应用在用户浏览器中的显示方式没有影响,除了在标题中显示配方名称的更改。这些变化确实对标记的解析和解释产生了显著的影响。如果我们通过同一个解析器运行新的标记,我们会得到以下概要:

第一部分是页面本身。页面中包含登录部分、nav标签中包含的菜单和内容部分。内容部分是我们的食谱文章——第三个(也是新的)无标题部分。如果每个部分都被命名,理想情况下,你应该只使用部分标签,如果你可以应用一个标题,我们会有一个轮廓分明的页面。我们将在应用中的其他表单页面上应用适当的标题。要查看这些变化,您可以查阅本书附带的代码。
除了这些新的语义标签之外,HTML5 还提供了通过一组新的属性(统称为自定义数据属性)将私有数据与任何 HTML 标签相关联的能力。
自定义数据属性
自定义数据属性有时被称为属性,这是有充分理由的。假设属性以data-开头,并且-后面的任何内容都比单个字符长,则该属性被归类为自定义数据属性,并将被视为自定义数据属性。当我们实现我们的食谱库时,我们实际上使用了其中一个data-*属性。
浏览器或检索标记的任何其他用户代理完全忽略自定义数据属性。它们对页面解析或页面呈现没有影响。它将被视为根本不存在。同样,页面的用户或任何二手消费者也应该忽略这些属性。
规范实际上将这些属性称为私有属性。这并不意味着它们不会出现在浏览器的源代码中,或者在传输到浏览器的过程中被神秘地从源代码中移除。如果用户查看页面的来源,或者如果外部代理选择从我们的应用中的页面解析data-*属性,他们当然可以这样做。在这种情况下,私有意味着意图,而不是实际实现。我们不应该在这些属性中存储敏感信息。
我们可以存储在这些属性中的东西是标签没有现有属性的东西。我们当然不会在这里存储元素标题,因为 HTML 标准规定所有标签都支持title属性。但是,我们可以为字段存储一些初始值,或者为标签或容器指定角色。
<a href="#" data-initiallink="{oldlink}">Changing Link</a>
我们对这些属性的使用实际上是无限的,它们是 HTML5 规范最强大的特性之一。
表单控件
HTML5 引入了新的表单控件类型,为用户提供了更好的数据输入。这些控件可通过input标签的type属性访问,并为应用用户提供更好的数据输入体验。支持的新输入类型列举如下:
color:这给用户呈现了一个拾色器。date:这为用户提供了选择日期的机制。datetime:这允许用户输入日期和时间,包括时区。datetime-local:这允许用户输入他们本地的日期和时间。email:这允许用户输入电子邮件地址。month:这为用户提供了选择月份的机制。number:允许用户输入数字。range:这为用户提供了一个控件,通常是一个滑块,用于选择一个范围内的值。search:这将输入转换为搜索框。tel:这是用来收集电话号码的。time:这为用户输入没有时区的时间提供了控制。url:这为用户提供了一个输入网址的控件。week:这允许用户输入特定的一周。
你可能会问自己这些有什么不同。事实上,这些类型提示浏览器如何收集被请求的信息。当遇到这些输入类型时,大多数浏览器将提供一个自定义控件来代替与输入相关联的传统文本框。
如果不支持input类型,或者如果浏览器不支持 HTML5(或者特别是 HTML5 表单控件),浏览器将呈现类型为text的输入。这是标准规定。未知输入类型将被视为文本。
我们的应用已经利用了一些新的表单控件。如果我们查看我们为添加评论而创建的表单,我们会看到 Opera Mobile 呈现的表单如下:

ASP.NET MVC 运行时实际上是基于我们从System.ComponentModel.DataAnnotations添加的验证规则为评级控制生成以下输出。
<input class="text-box single-line"
data-val="true"
data-val-number="The field Rating must be a number."
data-val-required="The Rating field is required."
id="Rating" name="Rating"
type="number"
value="" />
你会注意到在data-*属性中,输入的类型被设置为number。Chrome 和 Opera 看到了这一点,并向用户提供了一个自定义的输入控件来指示要输入的数字。输入不仅是定制的,以帮助用户输入一个数字,它将积极拒绝任何无法转换成数字的输入,并通过代理,执行我们的一些验证规则。
本地存储
本地存储是大多数浏览器厂商支持的字典式存储机制。它与客户端存储数据的传统方式(cookies)的不同之处在于,存储在浏览器本地存储中的信息不会随每个请求一起发送到服务器。对本地存储中的数据的访问是通过键/值对进行的,每个字典只能由创建它的站点访问。
假设我们有一个名为localStorage的变量,它是对浏览器本地存储机制的引用。我们可以通过以下方式之一将信息放入本地存储:
localStorage["key"] = value;
localStorage.setItem("key", value);
同样,从字典中检索数据看起来如下所示:
var value = localStorage["key"]
var value = localStorage.getItem("key");
数据实际上是以字符串的形式存储在字典中的,因此您需要在检索时自己进行类型转换。
地理位置
地理定位 API 为浏览器提供基于位置的服务。如果你需要知道你的用户在哪里,这就是你的应用编程接口。调用此应用编程接口将通知您的用户它的访问,并需要他们的许可。
通过navigator.geolocation物业获得地理定位服务。该属性提供了一个方法getCurrentPosition,该方法将回调方法作为参数。
navigator.geolocation.getCurrentPosition(tell_me);
在前面的例子中,tell_me回调将由地理定位 API 调用,不仅提供纬度和经度,还提供速度和航向等信息。
利用 HTML5 有很多令人兴奋的可能性,幸运的是,大多数移动浏览器都支持它。如果您对目标浏览器支持的功能有疑问,您应该查看mobilehtml5.org提供的功能兼容性表。
CSS3
正如 HTML5 对 HTML 4,CSS3 对 CSS2。它是级联样式表的下一个化身,带来了一些改进,例如名称空间、区域、过滤器和条件样式。
同样,和 HTML5 一样,CSS3 的深度和广度足以填满一系列书籍。事实上,它是如此之大,以至于 CSS3 工作组已经将标准分解成一系列模块,您可以在http://www.w3.org/Style/CSS/current-work查看。
我们将集中讨论媒体类型、选择器和媒体查询,因为它们适用于移动开发,但是在我们开始之前,我想提醒大家,在处理 CSS 时,C 意味着级联。样式将按照元素出现的顺序应用于元素。对你的风格要精确,确保你的元素和风格有有意义的名字。
媒体类型
媒体类型已经存在了一段时间,对 CSS3 来说并不陌生,但由于大多数人对它们并不熟悉,所以值得简单提一下。简而言之,媒体类型可用于为不同的渲染类型指定不同的样式。如果我想在屏幕上查看页面时将一组样式或样式表应用于页面,在打印页面时将一组样式或样式表应用于页面,我可以使用媒体类型来控制这一点。
<link href="styles.css" media="screen" rel="stylesheet">
<link href="print-styles.css" media="print" rel="stylesheet">
撰写本文时支持的媒体类型有all、aural、braille、embossed、handheld、print、projection、screen、tty和tv。
在本书的剩余部分,我们将主要使用all媒体类型。
CSS 选择器
CSS 选择器为我们提供了一种基于一条选择标准来选择一个标签或一组标签的机制。选择标准可能像标签的类型一样简单,也可能更复杂,比如从节点的第四个子节点开始,每隔七个子节点。
选择器有几种类型,它们的用法因类型而异,因此我们将简要讨论每种类型,并回顾几个示例,以便为我们提供足够的信息来应对危险。然而,这些信息将允许我们对这个主题做一些进一步的阅读,并加深我们的 CSS-fu。
类型选择器
CSS 类型选择器允许我们将一种样式应用于特定类型的所有元素。类型选择器的几个例子可以在我们的酿酒应用的Site.css文件中找到。
html {
background-color: #e2e2e2;
margin: 0;
padding: 0;
}
该选择器表示对于每个html标签(好的,是的,只有一个),设置background-color、margin和padding属性。类型选择器相当简单。
标识选择器
标识选择器允许我们对具有特定标识的元素应用样式。请注意,它适用于任何一致性文档中的元素,并且元素标识是唯一的。标识选择器用#字符表示。
#body {
background-color: #efeeef;
clear: both;
padding-bottom: 35px;
}
渲染时,任何 ID 为body的元素都将应用前面的样式。
属性选择器
如果你想根据一个属性的值或一个属性的存在来选择一个元素或一组元素,你需要使用属性选择器。属性选择器用括号([])表示,它们的选择标准允许您对值应用条件。
#loginForm input[type="checkbox"],
#loginForm input[type="submit"],
#loginForm input[type="button"],
#loginForm button {
width: auto;
}
来自我们的Site.css样式表的这段代码摘录设置了包含在任何具有loginForm标识的元素中的所有复选框、提交和按钮输入的宽度。属性选择器不限于type属性,也不限于等价性检查。可以查询任何元素的任何属性。下表列举了可以应用于属性选择器的条件:
情况
|
比赛
|
| --- | --- |
| [attr] | 将任意元素与属性{attr}匹配,无论属性值如何。 |
| [attr=value] | 匹配任何具有属性{attr}且值为{value}的元素。 |
| [attr~=value] | 如果属性{attr}是由空格分隔的单词组成的列表,假设其中一个单词等于{value},条件将匹配。 |
| [attr|=value] | 如果属性{attr}的值等于{value}或以{value-}开始,条件将评估为true并导致匹配。 |
| [attr^=value] | 如果属性值{attr}以{value}开头,则匹配。 |
| [attr$=value] | 如果属性值{attr}以{value}结束,则匹配。 |
| [attr*=value] | 如果属性{attr}的值包含值{value},则条件匹配。 |
类别选择器
类也可以作为 CSS 选择器标准。类别选择器通常用点(.)表示。
.float-left {
float: left;
}
任何应用了float-left类的类都将应用float: left样式。
由于class也是一个元素的属性,你可以使用属性选择器来定位和选择某些类的元素。
通用选择器
通用选择器用星号(*)表示。它们是全球性的,在实际应用中很少见到,因为它们的存在是隐含的。上一节中给出的示例类选择器也可以重写为:
*.float-left {
float: left;
}
星号使得选择器的通用范围显式。
伪类选择器
CSS3 中更强大的选择器类之一是伪类选择器。这些选择器允许我们对选择器应用简单的功能。通过使用冒号(:)来表示,伪类选择器提供了机制来获取包含在 文档对象模型 ( DOM 之外的标记信息。
最常见的一组伪类选择器是应用于锚点标签的:link、:visited、:active和:hover选择器。
a:link, a:visited,
a:active, a:hover {
color: #333;
}
其他伪类选择器提供了一种机制来检索特定位置、选中、启用或禁用元素的子元素。一个特别有趣的伪类选择器是:nth-child选择器,它允许我们指定选择的出现和偏移。:nth-child(4n-1)将从第三个子元素开始选择元素的每四个子元素。
CSS 选择器允许我们查询页面上的元素,但是 CSS 媒体查询允许我们查询显示页面的媒体的功能。
CSS 媒体查询
CSS 媒体查询为我们提供了一种机制,可以根据确定的特定(或当前)媒体类型的特征信息,有选择地应用样式。媒体查询涉及媒体类型和一个或多个特征查询的使用,在应用与查询相关联的样式之前,这些特征查询必须评估为真。
媒体查询可以使用link标签的传统媒体属性或使用 CSS 文件中的@media关键字来应用。
<link
rel="stylesheet"
media="all and (orientation:landscape)"
href="landscape.css">
@media all and (orientation:landscape) { /* styles */ }
媒体特征
当与媒体类型结合时,媒体特征完成了媒体查询的功能。关于媒体功能的一个重要注意事项是,大多数功能都支持min-或max-前缀。例如,width媒体功能支持min-和max-前缀,这意味着不仅可以查询宽度,还可以查询max-width和min-width。
特征
|
最小或最大前缀
|
描述
|
| --- | --- | --- |
| width | 是 | 用于查询媒体设备的当前、最小或最大宽度。 |
| height | 是 | 用于查询媒体设备的当前、最小或最大高度。 |
| device-width | 是 | 用于确定设备屏幕的实际宽度。这不同于当前窗口的宽度。 |
| device-height | 是 | 用于确定设备屏幕的实际高度。这不同于当前窗口的宽度。 |
| orientation | 不 | 用于查询媒体的方向。有效值为纵向或横向。 |
如果我们检查我们的Site.css样式表,我们当前有一个媒体查询。
@media only screen and (max-width: 850px)
当我们在本章后面讨论响应设计时,我们将讨论这一行提供的功能。
视口元标记
当谈论移动网络设计时,最后一条相当重要的信息是viewport meta标签。
大多数移动浏览器都假定,而且理所当然地假定,它们试图呈现的任何页面都是为桌面浏览器创作的,并打算在桌面浏览器中查看。在这种情况下,移动浏览器将以 980 像素宽呈现页面(通常,但有些浏览器可能使用替代分辨率),然后缩放呈现的页面以适合设备的屏幕。对于分辨率较低的设备,即使是我们在 Opera Mobile 模拟器中模拟的宽度为 480 像素的三星 Galaxy S II,这也意味着页面会被渲染,然后缩小到原始大小的 50%以下。这使得阅读变得困难,让用户感到沮丧。
当前在我们的_Layout.cshtml文件中作为<meta name="viewport" content="width=device-width" />的viewport meta标签是一个meta标签,用于指示移动设备在向用户显示页面时应该使用的分辨率和比例因子。我们的meta标签,通过将内容宽度设置为我们设备的宽度,告诉浏览器以原生分辨率呈现页面,对于我们的 Opera Mobile 模拟器,该分辨率为 480 像素宽。
注
viewport meta标签有许多属性和属性值。虽然这里提供的设置是默认设置,但我建议您多阅读一些关于该标签的信息,并确定是否有更好的值或值集供您使用。
正是这种价值让我们能够在应用中进行一些响应性设计。
响应性设计
而现在我们已经圆了。在第 3 章介绍 ASP.NET MVC 4中,我们谈到了响应性设计,以及开发试图在任何浏览器窗口中正确呈现自己的应用,并持续响应浏览器窗口大小或页面本身显示的内容的变化意味着什么。
在前一节中,我们了解到在我们的Site.css文件中有一个@media标签。
@media only screen and (max-width: 850px)
该媒体标签包含一组样式,适用于最大宽度为 850 像素的所有屏幕媒体类型,也就是说,适用于大多数移动设备。
我们还了解到,我们的_Layout.cshtml文件包含viewport meta标签,告诉移动浏览器以其原生分辨率呈现页面。让我们花一点时间把所有的东西放在一起。
当我们在分辨率为 850 像素或更低的网络浏览器中加载任何页面时,我们将应用一组样式来使页面在该分辨率下可用。将视口设置为使用移动设备的原生分辨率,并在 Opera 中加载此页面,会应用媒体定义中包含的一组样式。

如果我们桌面上的浏览器窗口低于 850 像素,同样的规则适用;它得到了与我们的移动模拟器相同的待遇。

然而,如果我们将浏览器的分辨率提高到 850 像素之外的宽度,比如 1024 像素,页面看起来会有明显的不同。

我们的导航和徽标现在不再居中,而是分别向右和向左对齐以更好地利用新提供的水平分辨率。就连登录链接的背景等小东西都变了。我们的网站已经调整到显示媒体的宽度。这就是响应性设计的本质。
响应列表
为了使我们的网站更具响应性,我们将对食谱列表进行一些调整。首先,显示原始重力和最终重力对于那些知道如何根据这些数字计算食谱酒精含量的人来说很棒,但这不应该是我们用户的必备技能。我们需要将RecipeDisplayViewModel类的PercentAlcoholByVolume属性添加到列表中。
不幸的是,我们的列表在移动设备上已经很拥挤了。在显示屏上添加另一个字段将使其实际上无法使用。为了适应这一点,我们将使设计具有响应性。对于小于或等于 600 像素的显示器,我们将只显示酒精的体积。如果显示器宽度大于 600 像素,我们将按体积、原始重力和最终重力显示酒精。
我们将首先在位于我们项目的Content文件夹中的Site.css中添加一个名为hide600的样式。样式定义如下:
@media only screen and (max-width: 600px) {
.hide600 {
display: none;
}
}
该样式规定,每当屏幕宽度低于 600 像素时,为应用该样式的元素设置display``none。这基本上隐藏了元素。当屏幕宽度超出 600 像素时,不定义此样式。这会将display的值重置为默认的值。
我们需要将这种风格应用于原始重力和最终重力柱。请记住在表格标题、表格单元格以及响应信号中枢的 JavaScript 中这样做。我们还将在重力场之前添加PercentAlcoholByVolume属性,所以记得在相同的地方调整列表。以下是调整后的表头代码。完整的代码可以在本书附带的包中找到:
<tr>
<th>
@Html.DisplayNameFor(model => model[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model[0].Style)
</th>
<th>
@Html.DisplayNameFor(model => model[0].PercentAlcoholByVolume)
</th>
<th class="hide600">
@Html.DisplayNameFor(model => model[0].OriginalGravity)
</th>
<th class="hide600">
@Html.DisplayNameFor(model => model[0].FinalGravity)
</th>
@if(Request.IsAuthenticated) {
<th>
</th>
}
</tr>
如果我们在谷歌 Chrome 中加载页面,我们会看到所有的列都存在。窗口的宽度超出了我们隐藏重力柱的 600 像素。

然而,我们的 Opera Mobile 浏览器的宽度为 480 像素。在那里查看页面有不同的列集。

处理响应性设计问题并不难,但总会有这样的情况,你既没有时间也没有愿望去创建一个完全响应性的布局。在这些场景中,您可以选择使用一个公共可用的响应设计框架,如 ZURB 的基础 或 Twitter Bootstrap 。这些工具包为您提供了一套完整的模板和类来帮助您满足相应的设计需求。
总结
在这一章中,我们了解了 HTML5,以及它的起源来自对 HTML 实际使用方式的研究和分析。这项研究的成果是一种新的语义标记,它关注的是内容是什么,而不是如何显示。我们还研究了 CSS3,以及它通过使用选择器和媒体查询,根据客户端的能力来最好地显示内容的能力。将这些技术与浏览器对视口控制的支持结合使用,我们可以通过一种称为响应设计的技术来控制用户体验。
在下一章中,我们将了解 ASP.NET MVC 4 内置的功能,它允许我们针对特定的移动设备,我们将定制我们的应用,为用户提供更好的移动体验。
十三、扩展对移动网络的支持
在前一章中,我们学习了如何设计和设计我们的应用,使其在移动平台和桌面浏览器上的外观和功能大致相同。对于某些应用来说,响应设计方法可能很好,但是在做出允许客户端处理所有移动演示的最终决定之前,有一些事情需要考虑。
开发一个移动网络应用需要我们以尽可能少的开销快速、轻松地向用户呈现信息。虽然这些总体上是很好的设计目标,但是移动用户的使用模式决定了这些目标是我们设计工作的重点。移动用户希望启动我们的应用,获取他们需要的信息,然后离开。为了适应这种使用场景,我们可能会决定我们需要一种与通过响应设计、媒体查询和视口标签的客户端呈现决定所能实现的体验完全不同的体验。
客户端确定的替代方案当然是在数据发送到客户端之前在服务器上进行呈现确定。这种方法有其自身的一系列缺点。对于初学者来说,确定服务器上客户端的功能并不容易。即使你在应用发布时说明了每台设备,但具有新功能的新设备仍在不断发布。
您还面临着在特定于移动设备或移动设备系列的视图中复制服务器上大量代码的风险。理想的解决方案是通过结合客户端和服务器端的决定来做出呈现决策。
碰巧的是,ASP.NET MVC 4 提供了对服务器端表示的支持,我们可以将其与现有的客户端响应设计逻辑相结合。
移动视图
如果我们检查我们的布局页面_Layout.cshtml,我们会看到我们的应用的大部分演示布局都包含在这个文件中。因此,如果我们创建一个专门针对移动设备的布局页面,我们肯定会走上将一些呈现逻辑从客户端卸载到服务器的捷径,这似乎是合乎逻辑的。
正如我们已经看到的,约定多于配置在 ASP.NET MVC 4 框架中非常普遍。在我看来,最巧妙的扩展点之一是能够在视图、局部视图或布局的名称中使用.Mobile扩展来定位移动设备。我们将开始通过移动布局扩展对移动网络的支持。
一、移动布局
首先在~/Views/Shared,中创建_Layout.cshtml文件的副本,并将副本重命名为_Layout.Mobile.cshtml。Shared文件夹的内容如下图所示:

打开新的_Layout.Mobile.cshtml文件,找到用于显示应用标题的行。
<p class="site-title">@Html.ActionLink("BrewHow", "Index", "Recipe", new { area = "" }, null)</p>
将文字BrewHow替换为BrewHow Mobile。当移动设备发出请求时,我们将使用此文本来验证我们的新布局正在被使用。
<p class="site-title">@Html.ActionLink("BrewHow Mobile", "Index", "Recipe", new { area = "" }, null)</p>
我们现在已经创建了一个布局页面,所有来自移动浏览器的请求都将使用该页面。说真的,这就是我们要做的。如果我们在 Opera Mobile 模拟器中启动我们的应用,我们将看到运行时确实在使用新的移动布局页面,这从我们的标题更改中可以明显看出。

为了证明这不是一个骗局,以下是该页面在谷歌浏览器中的显示方式:

正如本节开头提到的,这种技术适用于所有视图,而不仅仅是布局。每当我们想要创建以移动设备为目标的视图和部分视图时,我们可以简单地将.Mobile附加到视图的名称上。
移动用户需要快速方便地获取信息。牢记这一点,我们应该修改我们的其余观点,以提供最低数量的必要信息的重要性顺序。是时候动员酿酒公司了。
动员酿酒
让我们检查一下我们的应用的主页——一个主要目的是向用户返回食谱列表的页面——如下图所示:

我们的食谱列表页面以一个标题开始,该标题用作返回主页的链接、一句问候和页面顶部的导航链接列表,允许用户访问食谱列表(主页)、一个风格列表以及它们的库页面。接下来是页面标题和链接来创建一个食谱。只有在完成了所有的页面填充之后,我们才能得到页面的内容,一份食谱列表,列出了每个食谱的名称、风格和酒精含量。如果用户登录,还有一个额外的链接可以将配方添加到用户的库中。我们还需要记住,我们在第 12 章、为移动设备设计您的应用中隐藏了一些基于我们的响应设计工作的内容。
如果我们对出现在 Opera Mobile 模拟器中的应用进行诚实的评估,我们很难真正看到内容。如果我们在走路、锻炼或做任何其他我们在使用移动设备时经常做的活动,我们肯定无法阅读它。我们需要解决这些缺点。
去除内容物
我们需要做的第一件事是从食谱列表中删除无关的内容。到目前为止,这是我们能对应用做出的最大改进。它不仅将相关内容带到最前沿,还将使我们能够更好地利用大多数移动设备固有的较小屏幕。
让我们从头开始。标题和问候语对用户很有用。虽然我们可以将它们设计得更小,但它们满足了一个需求:通知用户它们在哪里,并通知他们已经登录了应用。
关于导航链接,如果我们考虑用户将如何使用我们的应用,他们通常会登录,搜索食谱或在他们的库中找到一个,查看关于食谱的信息,然后离开。他们几乎不会点击样式链接。可以移除样式链接。
我们的食谱列表也需要一些关注,正如您在下面的截图中看到的:

酒精含量不会成为用户的主要搜索标准。我们可以删除这个列,以及原始和最终重力的两个隐藏列。这将为菜谱的名称和风格创造一点空间,这样我们的展示就不会那么局促了。
似乎对_Layout.Mobile.cs文件所做的唯一更改是删除样式链接。我们的大部分更改将发生在包含实际内容的页面上。从_Layout.Mobile.cs文件中删除样式链接。
对于内容页面,首先从复制我们的Views\Recipe文件夹中的Index.cshtml文件开始,并将复制的文件重命名为Index.Mobile.cshtml。Recipe文件夹的新内容如下图所示:

打开刚刚在 Visual Studio 中创建的移动视图,应用识别的编辑内容。文件中的列表应与以下代码匹配:
<table id ="recipe-list">
<tr>
<th>
@Html.DisplayNameFor(model => model[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model[0].Style)
</th>
<th>
@if(Request.IsAuthenticated) {
<th>
</th>
}
</tr>
@foreach (var item in Model) {
<tr>
<td>
@Html.ActionLink(
item.Name,
"Details",
new { id=item.RecipeId, slug=item.Slug })
</td>
<td>
@Html.ActionLink(
Html.DisplayFor(modelItem => item.Style).ToHtmlString(),
"Style",
"Recipe",
new { style = item.StyleSlug },
null)
</td>
@if (Request.IsAuthenticated) {
<td>
@Html.ActionLink(
"Add to Library",
"Create",
"Library",
new { id = item.RecipeId },
new { @class = "add-recipe", data_id = @item.RecipeId })
</td>
}
</tr>
}
</table>
我们应该对图书馆的看法做出同样的改变。
至于显示配方细节的视图,有很多信息可以删除。创建一个Details.Mobile.cshtml文件,从新的移动视图中删除类别、原始重力、最终重力、酒精体积和贡献者。我们也可以从Details视图中删除Name字段,因为它出现在页面标题和页眉中。
我们现在向用户展示最基本的功能内容。我们现在需要做的就是优先考虑它呈现的顺序。
对内容进行优先排序
虽然我们应用的用户有可能会从他们的移动设备中添加新的啤酒配方——毕竟平板电脑是移动设备,但它的重要性肯定低于能够找到配方。通过将创建新食谱的链接放在我们列表的顶部,我们迫使用户向下滚动以获得他们正在寻找的内容。我们不想带走这种能力;我们只是想把它放在页面的底部。
在我们的Index.Mobile.cshtml视图中,将下面的代码移到分页控件下面的视图底部。请随意更改Create Recipe链接上的文字。
<p>
@if (Request.IsAuthenticated) {
@Html.ActionLink("Create New", "Create")
}
</p>
如果我们在 Opera Mobile 模拟器中构建并启动该应用,我们应该会看到一个完全不同的、更加移动友好的用户体验在等着我们。

对移动模板的支持是微软非常强大的礼物。但是权力越大,责任越大。我们真的需要了解这是如何运作的。
它是如何工作的
你永远不会老得不相信魔法。不幸的是,魔法不是这个过程的根源。那个.NET Framework 维护一个“已知”浏览器及其功能的列表。当处理传入请求时,会查阅已知浏览器的列表,以确定客户端的用户代理是否与已知浏览器匹配。如果找到匹配项,并且该浏览器的功能将其识别为移动浏览器,将使用任何存在的.Mobile视图处理该请求。
的标准 64 位安装.NET 框架,浏览器列表位于C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Config\Browsers处的文件系统上。该文件夹的内容如下图所示:

如果我们查看其中一个文件,我们会看到一个相当简单的 XML 文件。以下是浏览器定义文件iphone.browser的内容。该文件包含 iPhone、iPad 和 iPod 的识别信息和功能。
<browsers>
<!-- Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ (KHTML, like Gecko) Version/3.0 Mobile/1A543a Safari/419.3 -->
<gateway id="IPhone" parentID="Safari">
<identification>
<userAgent match="iPhone" />
</identification>
<capabilities>
<capability name="isMobileDevice" value="true" />
<capability name="mobileDeviceManufacturer" value="Apple" />
<capability name="mobileDeviceModel" value="IPhone" />
<capability name="canInitiateVoiceCall" value="true" />
</capabilities>
</gateway>
<!-- Mozilla/5.0 (iPod; U; CPU like Mac OS X; en) AppleWebKit/420.1 (KHTML, like Gecko) Version/3.0 Mobile/4A93 Safari/419.3 -->
<gateway id="IPod" parentID="Safari">
<identification>
<userAgent match="iPod" />
</identification>
<capabilities>
<capability name="isMobileDevice" value="true" />
<capability name="mobileDeviceManufacturer" value="Apple" />
<capability name="mobileDeviceModel" value="IPod" />
</capabilities>
</gateway>
<!-- Mozilla/5.0 (iPad; U; CPU OS 4_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8F191 Safari/6533.18.5 -->
<gateway id="IPad" parentID="Safari">
<identification>
<userAgent match="iPad" />
</identification>
<capabilities>
<capability name="isMobileDevice" value="true" />
<capability name="mobileDeviceManufacturer" value="Apple" />
<capability name="mobileDeviceModel" value="IPad" />
</capabilities>
</gateway>
</browsers>
定义文件中的gateway节点用于标识特定的浏览器或浏览器族。在iphone.browser文件中,每个网关节点包含一个名为parentID的属性,每个属性的值为Safari。您看到的是浏览器定义继承。
对于为苹果每个移动设备品牌提供动力的 iOS 平台来说,浏览器是 Safari 的衍生产品。因此,iphone.browser定义文件中的每个浏览器定义都继承了带有 Safari 的id的gateway节点的用户代理匹配和功能。Safari 浏览器的功能在safari.browser文件中定义。
如果我们想增加对新的移动浏览器的支持,我们当然可以向这个集合中添加新的文件,但是这似乎有点乏味。有人可能会提出这样的论点:如果我们试图识别一个不属于用.Mobile扩展名捕获的设备的,我们可能会尝试做一些特定于设备的事情。ASP.NET MVC 4 框架为我们提供了一种比编辑浏览器定义文件(显示模式)更简单的方法来定位那些特定的设备。
类型
严重检测
如果您对服务器端的浏览器检测和能力测试很认真,有几种公共(http://wurfl.sourceforge.net/)和商业产品(http://51degrees.mobi/)可以用来代替这些方法。
显示模式
显示模式为 n ew 至 ASP.NET MVC 4。它们允许我们根据包含在HttpContext中的一些匹配标准来定位特定设备,通常是用户代理字符串。匹配后,显示模式会识别附加到设备特定视图的后缀。实际上,这是我们刚刚了解到的.Mobile后缀背后的技术。
显示模式由System.Web.WebPages中定义的IDisplayMode界面的实现来表示。在应用启动时,IDisplayMode的实现可以在当前的DisplayModeProvider中注册。
我们将为华硕 Nexus 7 创建并注册一个IDisplayMode实例。我们的新显示模式将对任何华硕 Nexus 7 特定视图使用nexus7后缀。这意味着布局文件_Layout.nexus7.cshtml将用于华硕 Nexus 7 对我们网站的任何请求。
支持华硕 Nexus 7
我们必须首先用设备浏览器的用户代理字符串识别来自华硕 Nexus 7 的请求,这样我们才能有效地锁定它。Nexus 7 附带谷歌安卓 Chrome 作为默认浏览器,该浏览器使用以下用户代理字符串标识自己(您的定义可能略有不同):
Mozilla/5.0 (Linux; Android 4.1.1; Nexus 7 Build/JRO03D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Safari/535.19
马上,我们就可以识别用户代理内的字符串Nexus 7。我们会用它来瞄准那个装置。
创建显示模式
为了瞄准 Nexus 7 的,我们需要创建一个实现IDisplayMode的类的实例。IDisplayMode的实现必须满足两个标准;它必须识别来自 Nexus 7 的请求,并且必须为特定于该设备的视图指定后缀。
那个.NET Framework 附带了名为DefaultDisplayMode的IDisplayMode的默认实现,足以满足我们的需求。DefaultDisplayMode类有一个构造函数,它采用一个参数来指定与显示模式相关的后缀。
var nexus7DisplayMode = new DefaultDisplayMode("nexus7");
DefaultDisplayMode类还有一个ContextCondition属性,定义如下:
public Func<HttpContextBase, bool> ContextCondition { get; set; }
我们可以使用这个 lambda 属性来询问传入请求的HttpContext,并查看用户代理字符串是否包含字符串Nexus 7,从而将请求识别为来自我们的目标设备。
我们完整的DefaultDisplayMode定义如下:
var nexus7DisplayMode = new DefaultDisplayMode("nexus7")
{
ContextCondition = (context =>
context
.GetOverriddenUserAgent()
.IndexOf(
"Nexus 7",
StringComparison.OrdinalIgnoreCase
) >= 0)
};
登记显示模式
显示模式需要在我们的应用启动时和处理任何入站请求之前向显示模式提供商注册。向显示模式提供商注册我们的显示模式非常简单,如下所示:
DisplayModeProvider
.Instance
.Modes
.Insert(0, nexus7DisplayMode );
我们在位置0插入 Nexus 7 的显示模式,因为我们希望它取代任何其他可能与该设备匹配的显示模式。很像路由,找到的第一个匹配是要使用的。
为了与我们的其他代码保持一致,我们需要将这个代码放在我们项目的App_Start文件夹中,并从我们的Global.asax.cs文件中的MvcApplication类的Application_Start方法调用它。
右键点击App_Start文件夹,新建一个名为DisplayModeConfig.cs的类,内容如下:
public class DisplayModeConfig
{
public static void RegisterDisplayModes()
{
DisplayModeProvider
.Instance
.Modes
.Insert(0,
new DefaultDisplayMode("nexus7")
{
ContextCondition = (context =>
context
.GetOverriddenUserAgent()
.IndexOf(
"Nexus 7",
StringComparison
.OrdinalIgnoreCase
) >= 0)
}
);
}
}
在Global.asax.cs中MvcApplication类的Application_Start方法中,添加以下代码行:
DisplayModeConfig.RegisterDisplayModes();
我们现在准备测试我们的工作。
使用 Nexus 7 进行测试
最后一步是看我们是否已经成功定义并注册了 Nexus 7 的显示模式。在Views/Shared文件夹中复制_Layout.Mobile.cshtml文件,并将其命名为_Layout.Nexus7.cshtml。重命名文件后,将其打开,并将标题从BrewHow Mobile更改为BrewHow Mobile Nexus 7。完成后,构建并启动网站。
如果你手边有一个 Nexus 7,导航到你的本地网络上的网站。您应该会看到我们的 Nexus 7 特定页面:

注
从本地网络访问 IIS Express 需要编辑位于Documents文件夹中的IISExpress\config\applicationhost.config文件中的地址绑定。您可能还需要以管理员身份启动 Visual Studio 2012。
请注意,我们的 Nexus 7 显示模式取代了任何其他匹配。如果显示模式匹配,将使用后缀,并且仅使用与显示模式相关联的后缀。正如你在前面的截图中看到的,我们在 Nexus 7 布局主文件中看到了标准列表,因为我们匹配了一个后缀nexus7。
总结
在本章中,我们学习了如何创建移动视图,以及如何使用定制内容定位特定设备。目标是快速准确地向用户交付内容。这些服务器端工具最好与一些客户端呈现决定结合使用。再加上我们在第 12 章、为移动设备设计你的应用中学习到的响应性设计技术,有可能构建出真正坚实的网络应用,在标准桌面和移动平台上都运行良好。
然而,如果我们想为用户创造真正的移动体验,我们必须将我们的移动应用提升到一个新的水平。在下一章中,我们将研究最流行的客户端演示工具包之一:jQuery mobile。借助 jQuery mobile,我们将修改 BrewHow,使其外观和感觉像一个原生的移动应用。
十四、使用 jQuery Mobile 改善用户体验
在前一章中,我们学习了如何创建移动设备特有的视图。除了内容更改之外,这些视图还使用了响应设计和自适应渲染技术,以使我们的应用在用户浏览器中看起来和性能更好。话虽如此,它看起来和感觉仍然像一个网络应用。我们真正想做的是为我们的用户提供一种体验,让他们忘记他们正在看一个网络应用。我们可以使用 jQuery Mobile 提供这种体验。
jQuery Mobile 是一个针对移动浏览器的用户界面框架。它旨在通过一次写入的方式为移动网络应用提供设备原生的外观。
jQuery Mobile 能够同时针对多个浏览器,因为它采用了渐进式增强方法。简而言之,渐进式增强是一种设计方法,允许基线内容和布局面向各种浏览器。当向用户显示内容时,通过 CSS 和 JavaScript 对内容进行了查看浏览器能力范围内的增强。
可以说,jQuery Mobile 最吸引人的特性是语义标记的使用。通过使用自定义数据属性,即我们在第 12 章、为移动设备设计您的应用中讨论 HTML5 时了解到的data-*属性,我们可以将 HTML 标签标记为 jQuery Mobile 小部件。然后,通过渐进式增强,只有能够向用户显示的小部件才能通过移动外观得到增强。
注
如果您决定不使用自定义数据属性在标记中提供表示提示,您也可以直接实例化 jQuery Mobile 小部件,但是您将错过框架的一个非常强大的功能。
要了解 jQuery Mobile 使用起来有多简单,最好的方法就是实际使用它,所以让我们开始吧。
安装 jQuery 手机
如果我们使用移动应用模板启动我们的应用,我们会立即获得 jQuery 移动库。由于我们使用互联网应用模板启动了我们的应用,我们需要将库添加到我们的项目中。我们将使用软件包管理器控制台从 NuGet 软件包安装它。
jQuery Mobile NuGet 包包含一个名为_Layout.Mobile.cshtml的文件,与我们在上一章创建的手机布局同名。为了正确安装软件包,我们首先需要从现有项目中删除_Layout.Mobile.cshtml文件。它将被替换为包中的那个。右键单击我们项目中的_Layout.Mobile.cshtml文件并将其删除。删除布局时,继续删除 Nexus 7 布局。我们希望所有移动设备都以 jQuery Mobile 布局为目标。
现在我们可以通过从工具菜单中选择库包管理器来打开包管理器控制台,并通过在控制台中执行以下操作来安装 jQuery Mobile MVC 包:
Install-Package jQuery.Mobile.MVC
这个包的安装修改了我们的项目,在_Layout.Mobile.cshtml之外增加了几个新的文件。简而言之,我们应该在我们的项目中找到以下文件:
-
移动 jQuery
-
移动 CSS 文件和支持图像
-
框架
-
_ViewSwitcher.cshtml,部分视图,提供在桌面和移动视图之间切换的能力 -
包含用于支持部分视图切换器的控制器的
ViewSwitcherController.cs文件 -
A new jQuery Mobile bundle definition in
BundleMobileConfig.cs注
这些文件代表了从包版本 1.0.0 开始的 jQuery Mobile MVC NuGet 包的内容。
启用 jQuery 移动捆绑包
jQuery 移动包包括 CSS 和 jQuery 移动库的捆绑包。这些包在我们项目的App_Start文件夹中添加的BundleMobileConfig.cs文件中定义。
安装软件包后,我们会看到一个包含以下文本的readme.txt文件:
要启用默认移动视图,您必须在Global.asax.cs中添加以下行作为Application_Start方法的最后一行:
BundleMobileConfig.RegisterBundles(BundleTable.Bundles);
让我们打开Global.asax.cs文件,按照readme.txt文件指示注册我们的移动捆绑包:
ServiceLocatorConfig.RegisterTypes();
AreaRegistration.RegisterAllAreas();
WebApiConfig.Register(GlobalConfiguration.Configuration);
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
BundleMobileConfig.RegisterBundles(BundleTable.Bundles);
AuthConfig.RegisterAuth();
DisplayModeConfig.RegisterDisplayModes();
现在,我们的应用已经在 jQuery Mobile 环境下为移动设备启用了,所以让我们看看有什么新功能。
查看结果
在我们可以启动 app 之前,我们需要对新的_Layout.Mobile.cshtml文件做一个小的改动。我们的视图正在布局中寻找名为Scripts的部分。jQuery Mobile 包提供的移动布局中不存在此部分。这个很简单就能搞定;只需在结束正文标签前将以下代码添加到_Layout.Mobile.cshtml文件中:
@RenderSection("Scripts", false)
现在,如果我们构建并启动我们的 BrewHow 应用,在 Chrome 中查看该应用时不会有任何差异,但在 Opera Mobile 模拟器中查看该应用时,差异会相当明显:

除了内容之外,该网站的移动版本与我们之前的移动版本几乎没有相似之处。让我们检查一下我们的移动应用的新布局文件,了解 jQuery Mobile 是如何被用来创建这种新的外观和感觉的。
jQuery 手机的布局
新的 jQuery Mobile 布局相当简单。事实上,它比我们在上一章中构建的初始移动布局小得多:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>@ViewBag.Title</title>
<meta name="viewport" content="width=device-width" />
@Styles.Render("~/Content/Mobile/css", "~/Content/jquerymobile/css")
@Scripts.Render("~/bundles/jquery", "~/bundles/jquerymobile")
<script>
$(document).ready(function () {
$.mobile.ajaxEnabled = false;
});
</script>
</head>
<body>
<div data-role="page" data-theme="a">
@Html.Partial("_ViewSwitcher")
<div data-role="header">
<h1>@ViewBag.Title</h1>
</div>
<div data-role="content">
@RenderSection("featured", false)
@RenderBody()
</div>
</div>
@RenderSection("Scripts", false)
</body>
</html>
我们新的布局包括熟悉的元素,如视口meta标签。还要注意,有些元素虽然相似,但也有变化。例如,样式和脚本包现在包括在BundleMobileConfig.cs文件中标识的 jQuery Mobile 样式和脚本。
我们手机应用的其余变化都是通过 jQuery Mobile 的魔力完成的。一些变化是由标记提示触发的,例如data-*属性。其他更改,如触摸友好控件,由框架自动提供。让我们再深入一点,好吗?
数据-角色和数据属性
当我们在第 12 章、为移动设备设计你的应用中了解到的数据属性(data-*)时,我们了解到它们真的是句法糖。它们可以用于存储关于 HTML 元素的附加信息,但对该元素的呈现或显示没有影响。这个功能是 jQuery 逐步增强的关键。它使用这个语义标记将标准的 HTML 元素转换成 jQuery Mobile 小部件。具体来说,jQuery Mobile 查找具有data-role属性的 HTML 元素,并尝试将它们转换为属性值中标识的 jQuery Mobile 小部件。这种转换包括控件主题的应用和小部件特定的行为。
在我们的新布局中,我们使用data-role属性将三个标准 HTML 元素标识为 jQuery Mobile 小部件:page、header和content。其他可用的小部件类型包括按钮、列表视图、滑块、复选框、单选按钮、站点导航等。该列表非常详尽,但在 jQuery Mobile 网站上有完整的详细信息。
我们将很快看到,每个特定的小部件也支持一组data-*数据属性,这些属性定义了data-role中标识的小部件的行为。例如,我们可以将数据属性data-fixed应用于header的data-role的div。该属性的应用将防止标题与页面内容的其余部分一起滚动。
是的,虽然您可以在 JavaScript 中动态声明小部件,但是 jQuery Mobile 真正的强大之处在于语义标记。
形态元素
除了到使用语义标记识别小部件,jQuery Mobile 还免费给了我们几样东西。开箱即用,它将把我们的标准 HTML 表单元素转换成触摸友好的小部件。为此,jQuery Mobile 库解析我们应用的 DOM,并将 jQuery Mobile CSS 包中定义的新样式应用到它找到的表单元素中。我们不需要以data-roles的形式提供任何提示,我们只需要将元素封装在form标签中,并确保每个输入字段都有相关的标签。
如果处理得当,外观的变化会非常显著,如我们的登录屏幕所示:

我们的登录页面在我们的输入上有漂亮的圆角。用于记住我们的用户登录的复选框现在是触摸友好的,并且横跨屏幕宽度。使用 BrewHow 凭据登录或使用谷歌登录的按钮也进行了修改,以便在移动设备上更容易按下。所有这些都是免费的,只要我们维护有效的 HTML5 标记。
主题
如果你曾经使用过 jQuery 用户界面,你可能对它提供的广泛主题化功能很熟悉。在这方面,jQuery Mobile 步其更老的兄弟的后尘。它对主题化有广泛的支持,总共支持 26 个主题。每个主题都被认为是字母表中的一个字母。
要应用特定主题,只需将data-theme数据属性添加或更改到用于识别主题的字母表的字母中。除了页眉和页脚之外,每个小部件都继承其父部件的主题,除非明确分配了不同的主题。页眉和页脚默认为主题a,除非明确配置为使用不同的主题。
移动模板的默认布局使用页面的数据角色在元素上设置主题:
<div data-role="page" data-theme="a">
以下是应用了主题“e”的登录页面:

这是通过简单地将data-theme属性的值从a更改为e来完成的。
$。可动的
除了改变应用的外观之外,jQuery Mobile 还将使用其他渐进式增强技术来尝试让移动应用尽可能地具有设备原生的感觉。为了实现这一点,jQuery Mobile 将尝试使用 AJAX 技术异步处理所有内容请求。一旦检索到内容,该框架就采用诸如滑动内容等过渡来更好地模仿原生应用。
虽然这种行为确实为用户提供了更好的体验,但它改变了人们可能习惯于看到的由 jQuery 或其他库触发的事件的顺序和存在。如果您熟悉这种行为并且正在开发新的应用,那么这种更改可能不会有问题。然而,如果你正在将现有的应用转换为更加移动友好的应用,正在利用第三方库来挂钩某些页面级别的事件,如document.ready,或者两者的结合,这种变化是显著的。如果你没有意识到这种行为,它可能是无法调试的。
jQuery Mobile 库的作者知道对一个典型的应用事件生命周期的更改会非常不和谐,所以他们为我们提供了$.mobile对象,我们可以用它来覆盖一些全局行为。检查我们的新布局页面显示 NuGet jQuery Mobile 包的包已经关闭了这些转换,知道它们可能会给我们带来问题,直到我们熟悉库的复杂性:
<script>
$(document).ready(function () {
$.mobile.ajaxEnabled = false;
});
</script>
注
jQuery Mobile 还支持mobileinit等新事件。虽然我们的应用将通过在document.ready处理程序中应用这种特定的覆盖行为来运行良好,但是应该注意的是,jQuery Mobile 将在加载后立即开始应用标记增强,这可能早在document.ready事件触发之前。当 jQuery Mobile 本身启动时mobileinit事件将会触发,并且对全局行为的任何覆盖都应该真正应用于响应此事件通知。
视图切换器
虽然不是 jQuery Mobile 的部分,但 jQuery Mobile 布局附带了一个非常有用的部分控件,可用于在我们应用的桌面和移动版本之间切换。视图切换器分为用于嵌入视图的部分视图(_ViewSwitcher.chstml)和用于处理在应用版本之间切换的请求的控制器(ViewSwitcherController):
@Html.Partial("_ViewSwitcher")
局部控制本身相当简单;它将两个值传递给ViewSwitcherController。第一个值表示是否使用网站的移动版本。第二个值是用户在处理完请求后应该返回的网址。为了完成覆盖,控制器利用了 ASP.NET MVC 4 中一种新的扩展方法,命名为SetOverriddenBrowser:
public RedirectResult SwitchView(bool mobile, string returnUrl) {
if (Request.Browser.IsMobileDevice == mobile)
HttpContext.ClearOverriddenBrowser();
else
HttpContext.SetOverriddenBrowser(mobile ? BrowserOverride.Mobile : BrowserOverride.Desktop);
return Redirect(returnUrl);
}
在内部,SetOverriddenBrowser方法在浏览器中设置一个 cookie。运行时使用该 cookie 来评估应该返回网站的哪个版本。如果存在,则使用 cookie 中的值。如果 cookie 不存在,则使用发出请求的用户代理的默认显示模式,无论是.Mobile还是我们在上一章中创建的自定义版本。
现在了解了所有这些部分是如何协同工作的,让我们将一些 jQuery Mobile 功能应用于 BrewHow 的其余部分,使其成为真正独特的移动体验。
动员酿酒
通过安装 jQuery Mobile NuGet 包,并使用新的布局模板,我们可以宣布成功动员我们的网站。它确实提供了很多现成的功能,对于大部分网站来说已经足够了。然而,我们正在为我们的用户寻找一种更加原生的体验。为了提供这一点,我们需要从日常使用的移动应用中提取一些用户界面队列,并将这些知识与我们在移动网络开发方面学到的知识结合到我们的应用中。
调整表头
我们日常使用的大多数移动应用都会为我们提供一个标题,提供一些基本功能,或者至少是应用的标题。我们的应用应该没什么不同。
在我们的标题中,我们应该包括导航,以返回到我们的应用的起始页:食谱列表。我们还想让用户知道他们正在运行 BrewHow 移动应用,所以我们可能应该包括一个简单的标题。鉴于用户实际上是一个用户,应该为他们提供某种机制来登录和注销我们的网站。最后,我们希望我们的标题包括导航的基本站点。
打开_Layout.Mobile.cshtml文件,用以下代码替换header标签的内容:
<header data-role="header">
<a href="~/"
data-icon="home"
data-iconpos="notext">Home
</a>
<h1>BrewHow</h1>
@Html.Partial("_LoginPartial")
@if (Request.IsAuthenticated)
{
<nav data-role="navbar">
<ul>
<li>@Html.ActionLink(
"Recipes",
"Index",
"Recipe",
new { area = "" },
null)
</li>
<li>@Html.ActionLink(
"My Library",
"Index",
"Library",
new { area = "" },
null)
</li>
</ul>
</nav>
}
</header>
我们需要为此功能修改的另一段代码是登录的部分视图:_LoginPartial.cshtml。我们需要在用户移动设备对我们施加的限制范围内运作。向未经身份验证的用户显示注册和登录链接,或者向当前经过身份验证的用户显示整个问候语,在大多数移动设备上是不合适的。
为此,我们需要为我们的应用创建一个新的部分登录视图。复制Shared视图文件夹中的_LoginPartial.cshtml文件,并重命名复制的版本_LoginPartial.Mobile.cshtml。用以下内容替换文件内容:
@if (Request.IsAuthenticated) {
<a href="javascript:document.getElementById('logoutForm')
.submit()"
data-role="button"
class="ui-btn-right">
Log off
</a>
using (Html.BeginForm(
"LogOff",
"Account",
FormMethod.Post,
new { id = "logoutForm" }))
{
@Html.AntiForgeryToken()
}
}
else
{
@Html.ActionLink(
"Log in",
"Login",
"Account",
new { area = "" },
new { data_role="button", @class="ui-btn-right"})
}
新的登录控件将仅用于移动设备,并根据用户的认证状态显示登录和注销的单个按钮。
对于匿名用户,我们的应用现在呈现一个类似如下的标题:

对于经过身份验证的用户,导航将出现,看起来应该类似于以下内容:

让我们检查一下现在在我们的移动布局中出现的每个元素。
主页按钮
位于我们应用标题左上角的主页按钮是以下标记的渲染输出:
<a href="~/"
data-icon="home"
data-iconpos="notext">Home
</a>
这段代码只是一个锚,它有两个相关的data-*属性。jQuery Mobile 支持包附带的 CSS 文件中的几个图标。要指示 jQuery 将图标作为小部件的一部分呈现,我们只需使用data-icon数据属性,并将该值设置为支持的名称之一。
仅选择元素支持data-icon属性。虽然元素列表是有限的,但是支持数据图标属性的任何元素都支持整个图标列表。
类似地,data-iconpos数据属性指示 jQuery Mobile 框架将图标相对于控件中的任何文本放置在何处。
在撰写本文时,data-icon和data-iconpos的完整值列表可在http://jquerymobile . com/demos/1 . 1 . 2/docs/button/button-icons . html上找到。
登录用户
我们认证状态的 app 外观在移动版中有了很大的简化。我们的移动应用只提供了一个登录或注销按钮,而不是向未经身份验证的用户显示登录和注册链接以及向经过身份验证的用户显示问候和注销链接。
出于几个原因,我们简化了这个过程。首先,app 的主要功能是让用户找到菜谱。虽然真正经过认证的用户可能希望从他们的库中提取配方,但他们也可以简单地打开应用从配方列表中提取配方,而不需要向网站进行认证。
简化提示的第二个原因是由于移动设备上的空间限制。根本没有空间向未经身份验证的用户显示注册和登录链接。同样,我们不能在标题中显示问候语。对于移动版本,我们完全牺牲了问候,并提供了从我们的应用登录页面进入注册过程的途径。在当今的互联世界中,大多数用户可以假设他们将被允许从登录页面创建帐户,我们在设计应用时就考虑到了这一假设。
站点导航
关于导航首先要指出的是,它只对已经登录 app 的用户可用。遵循我们早期学习的移动应用开发原则,我们只需要呈现与用户想要完成的任务相关的信息。因为只有经过身份验证的用户才能拥有可用的菜谱库,所以未经身份验证的用户在导航栏中只有一个元素。考虑到导航栏占据了用户屏幕相当大的一部分,用一个功能占用这个空间似乎相当浪费,所以当用户匿名使用我们的应用时,我们会移除导航栏。
为了将 jQuery Mobile 样式应用于布局的nav元素中包含的导航元素,我们简单地添加了一个值为navbar的data-role属性。这指示 jQuery 将元素呈现为navbar小部件:
<nav data-role="navbar">
创建页脚
在 BrewHow 应用中,我们不需要在页脚中放置太多信息。我们应用的桌面版本只是显示版权声明,虽然这可能很有用,但我们的移动网站肯定不需要它,因为空间可以最好地用于其他方面。
对于 BrewHow,我们应该将视图切换器从页眉移到页脚。通过将它放在屏幕底部,我们可以将更多相关内容放在应用可见窗口的更高处。要创建 jQuery 移动页脚,我们只需将footer的data-role添加到移动布局模板的footer元素中:
<footer data-role="footer" data-position="fixed">
@Html.Partial("_ViewSwitcher")
</footer>
您会注意到我们的footer元素中还有一个data-position数据属性。data-position属性的值为fixed。该属性和值对锁定屏幕底部任何可滚动内容之前的页脚:

桌面页脚
我们应用的桌面页脚也需要调整。如果移动设备上的用户选择切换到我们应用的桌面版本,他们可能需要返回到移动视图的能力。做出这种改变非常简单。我们只需将视图切换器控件添加到桌面页脚:
<footer>
<div class="content-wrapper">
<div class="float-left">
<p>© @DateTime.Now.Year - My ASP.NET MVC Application</p>
</div>
</div>
@Html.Partial("_ViewSwitcher")
</footer>
可以看到视图切换器出现在桌面页脚,如下图所示:

在对我们的应用外观进行了返工后,让我们将重点转移到调整内容上。
配置内容
我们 app 的首要目的是收集和分享酿酒食谱。这些食谱是我们应用的内容驱动因素。因此,我们需要让他们在我们的移动网络应用中完全可访问。为此,我们需要对内容的布局方式进行一些调整。
食谱列表
配方列表仍显示为表格数据。这样做的问题是,表格显示通常与本地移动应用无关。此外,当我们以垂直方向在屏幕上显示多列时,大部分数据将被包装,或者在支持 jQuery Mobile 的应用中,被相邻的列遮挡。我们需要将表格转换为更适合移动设备显示的内容。
jQuery 移动列表视图
jQuery Mobile 提供了一个 listview 小部件,我们可以用它来显示我们应用中包含的食谱。正如你现在可能已经猜到的,它是通过使用data-role数据属性定义的。当应用listview的data-role值时,我们可以将标准的 HTML 列表转换为 jQuery Mobile listview 小部件。以下为RecipeController修改后的手机Index视图标注:
@model BrewHow
.ViewModels
.ITypedPagedResult
<BrewHow.ViewModels.RecipeDisplayViewModel>
@{
ViewBag.Title = ViewBag.Title ?? "Recipes";
}
<ul data-role="listview">
@foreach (var item in Model)
{
<li>
@Html.ActionLink(item.Name, "Details", new
{
id=item.RecipeId,
slug=item.Slug
})
</li>
}
</ul>
<p>
@{ Html.RenderPartial("PagingPartial", Model); }
</p>
<p>
@if (Request.IsAuthenticated) {
@Html.ActionLink("Create Recipe", "Create")
}
</p>
这当然是一个改进,但是页面上显示的信息仍然紧密地组合在一起,即使在为移除样式和向库中添加食谱的能力做出调整之后:

因为我们正在开发一个移动应用,我们需要考虑用户的输入法:他们的手指。为了更好地适应触摸输入,我们需要将内容进一步分离,以便为用户提供更好的体验。
我们可以从开始,通过插入列表,将其与导航和创建链接分开来改善体验。要插入列表,我们需要将另一个数据属性(称为data-inset数据属性)应用于ul元素,并为该属性提供一个值true:
<ul data-role="listview" data-inset="true">
插入列表会导致我们的外观和感觉发生以下变化:

不幸的是,我们的内容现在看起来有点稀疏,所以让我们来解决这个问题。
扩展列表视图内容
当我们将显示从表格转换为 jQuery Mobile listview 小部件时,我们从配方列表中删除了配方样式。我们实际上有能力在不严重影响显示的情况下将信息添加回列表。
在 jQuery Mobile listview 小部件中,listview 第一个锚点中包含的所有内容都被设计为 listview 项目。如果我们调整列表项的内容以包含具有多个内容项的单个锚点,我们可以成功地将配方样式重新插入到内容中:
@foreach (var item in Model)
{
<li>
<a href="@Url.Action("Details", new
{
id = item.RecipeId,
slug=item.Slug
})">
<h3>@Html.DisplayFor(modelItem => item.Name)</h3>
<p>
@Html.DisplayFor(modelItem => item.Style)
</p>
</a>
</li>
}
类型
补充列表视图内容
还有其他方法可以将附加内容放入 listview。jQuery Mobile 附带了几个额外的样式,其中一些可以用来控制信息在列表视图中的显示方式。如果我们希望显示右对齐的内容,我们可以将ui-li-aside样式应用于包含在列表项目的锚点内的内容。如果我们希望计数气泡出现在列表项名称的右侧,我们也可以应用ui-li-count样式。
我们已经从稀疏变成相当冗长了。增加更多的内容是可以的,只要它是相关的和有意义的。不过,我们确实需要为用户提供过滤的能力。
列表视图过滤器
当显示大量数据时,可能需要为用户提供过滤内容的机制。listview 小部件通过使用data-filter数据属性来提供这一功能。要启用它,我们只需要将属性添加到 listview 小部件中,并将其值设置为 true。我们甚至可以调整占位符文本,即使用数据属性给用户的提示:
<ul
data-role="listview"
data-inset="true"
data-filter="true"
data-filter-placeholder="Search for a recipe...">
此功能仅搜索列表中当前显示的内容。我们将在这本书的最后一章讨论搜索所有的食谱。至于创造食谱的能力,我们需要在那里做一个小调整。
按钮
我们需要对配方列表页面进行的最后一个调整是将创建新配方的链接转换成更友好的内容。jQuery Mobile 可以通过将链接转换为触摸友好的按钮小部件,你猜对了,data-role数据属性:
@Html.ActionLink(
"Create Recipe",
"Create",
null,
new { data_role = "button" }
)
注
当向包含破折号的动作链接提供 HTML 属性时,我们必须使用下划线将它们提供给动作链接 HTML 帮助器。运行时足够智能,当它呈现到输出流的链接时,可以将下划线转换为破折号。
我们的食谱列表视图是完整的,但是如果用户不能确定他们实际上在看哪个屏幕,它就相当没有意义了。
导航提示
在查看食谱列表时,不清楚我们是在查看全球食谱列表,还是在查看我们的库中的食谱。我们需要提供一个导航提示来向用户阐明这一点。要在移动布局中突出显示我们的navbar小部件中的一个按钮,我们需要将ui-btn-active样式应用于活动导航元素:
<li>@Html.ActionLink(
"Recipes",
"Index",
"Recipe",
new { area = "" },
new { @class="ui-btn-active" })
</li>
我们所有工作的输出如下图所示:

食谱列表现在看起来、感觉和功能都像一个原生的移动应用。是时候关注细节页面了。
配方详情
当在 BrewHow 手机应用中显示菜谱的详细信息时,我们需要维护几个功能:返回上一页的能力、添加和查看菜谱评论的能力以及编辑菜谱的能力。我们希望以 jQuery Mobile 支持的方式完成所有这些工作。我们当前对RecipeController的Detail视图充满了样式和布局元素,它们是特定于我们旧的移动模板附带的 CSS 的。我们需要对RecipeController的Detail视图做一些实质性的清理工作。
我们将用于手机应用的新视图的相关代码显示如下:
<article id="recipe-detail">
<header>
<h2>
<a href="#"
data-role="button"
data-icon="back"
data-iconpos="notext"
data-inline="true"
onclick="javascript:history.go(-1)">
Back
</a>
@Model.Name
</h2>
</header>
<section>
<header>
<strong>
@Html.DisplayNameFor(model =>model.PercentAlcoholByVolume)
</strong>
</header>
@Html.DisplayFor(model => model.PercentAlcoholByVolume)
</section>
<br>
<!-- Other section fields omitted -->
</article>
<div>
@if (Request.IsAuthenticated)
{
@* Buttons to add to library and edit recipe *@
}
@Html.ActionLink("View Reviews",
"Index",
new
{
area = "Review",
id = Model.RecipeId
},
new { data_role = "button" })
@if (Request.IsAuthenticated)
{
@Html.ActionLink(
"Add Review",
"Create",
new
{
area = "Review",
id = Model.RecipeId
},
new { data_role = "button" });
}
</div>
让我们研究一下这个新视图如何满足我们在本节开始时确定的用户需求。
后退按钮
创建后退按钮使用的技术与我们在标题中创建主页按钮时使用的技术相同。唯一值得注意的重大变化是data-icon数据属性的值和我们附加到锚点的内联 JavaScript:
<a href="#"
data-role="button"
data-icon="back"
data-iconpos="notext"
data-inline="true"
onclick="javascript:history.go(-1)">Back</a>
注
我们可以使用 jQuery 来提供后退按钮功能,而不是内联 JavaScript,如果我们正在编写生产就绪代码,我们可能应该这样做。如果您选择在自己的生产移动应用中使用部分内容,请进行相应调整。
动作按钮
用户在查看配方时可能采取的操作已转换为按钮。这些操作包括向库中添加配方、编辑配方、查看配方或查看配方的所有评论的能力。我们还将用户界面中的库、配方编辑和审查功能的访问权限限制为只有经过身份验证的用户。这些限制也在我们的控制人员中实施:
@if (Request.IsAuthenticated)
{
@Html.ActionLink("Edit Recipe", "Edit", new
{
id = Model.RecipeId,
slug = Model.Slug
},
new { data_role = "button" });
}
@Html.ActionLink("View Reviews", "Index", new
{
area = "Review",
id = Model.RecipeId
},
new { data_role = "button" })
@if (Request.IsAuthenticated)
{
@Html.ActionLink("Add Review", "Create", new
{
area = "Review",
id = Model.RecipeId
},
new { data_role = "button" });
}
所有这些变化导致了新的和改进的配方细节屏幕,如下图所示:

下一站:从移动设备编辑食谱的能力。
配方编辑
我们的手机应用目前没有编辑食谱的手机专用视图。虽然我们的大多数用户确实会使用移动应用来快速检索信息,但也会有用户希望通过他们的移动设备为酿酒做出贡献。因此,我们应该让编辑体验尽可能愉快。
让我们从开始,在Views文件夹中为我们的配方控制器制作一份Edit.cshtml文件的副本,并将新副本重命名为Edit.Mobile.cshtml。重命名完成后,打开新的移动编辑视图。在大多数情况下,我们的移动编辑视图在大多数移动设备上都是可用的。不过,我们可以做得更好。
与详细视图一样,我们需要支持用户返回上一页的愿望。添加后退按钮几乎与详图中定义的练习完全相同:
<a href="#"
data-role="button"
data-icon="back"
data-iconpos="notext"
data-inline="true"
onclick="javascript:history.go(-1)">Back</a>
我们需要做的更大的改变是从我们的视野中移除fieldset和legend元素。它们将被fieldcontain风格所取代。
现场容器
fieldcontain样式是在 jQuery Mobile CSS 中定义的样式。当布局输入表单时,它用于支持响应设计。这个 CSS 类应用于封装标签和输入元素的容器。它试图以最适合设备的方式显示标签和输入。
当处理异常低的分辨率时,fieldcontain样式将强制浏览器在输入上方布局标签。但是,如果呈现页面的设备有足够的水平分辨率来显示输入旁边的标签,那么它就会这样做。
要将fieldcontain样式应用到我们的移动编辑视图中,我们需要将相关的标签和输入移动到一个容器中。在我们看来,容器是一个 div。然后,我们需要对每个 div 应用fieldcontain样式,包装一个标签和字段对。
举例来说:
<div class="editor-label">
@Html.LabelFor(model => model.Instructions)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Instructions)
@Html.ValidationMessageFor(model => model.Instructions)
</div>
前面的代码如下:
<div class="fieldcontain">
@Html.LabelFor(model => model.Instructions)
@Html.EditorFor(model => model.Instructions)
@Html.ValidationMessageFor(model => model.Instructions)
</div>
新的编辑视图如下图所示:

评论
对我们新手机应用流量影响最大的是评论的进入和查看。在我们应用的桌面版和响应设计版本中,评论会显示配方的详细信息。在移动领域,这可能不是一个好主意。
如上所述,当我们为移动设备编写应用时,我们需要确保只向用户返回请求的信息。附加信息的返回意味着用户将不得不等待更长的时间,以使其显示在他们的设备上,并且将不得不过滤呈现给他们的所有信息,以便找到他们实际上正在寻找的信息。
我们仍然应该向我们的用户提供对评论的访问,但是我们应该只在用户明确要求时才返回评论。这就要求我们把评论移到新的一页。当我们在本章的食谱详细视图中添加查看评论和添加评论按钮时,我们做了这个假设。如果我们去点击按钮查看评论,你会看到有些东西有点不对劲:

首先,也是最明显的,我们的评论视图没有与之相关的样式。它缺少了 jQuery Mobile 风格和与我们的桌面视图相关的风格。
或许不太明显的是viewport meta标签的缺失。没有这个标签,浏览器就试图将一个“桌面”网站缩放到移动设备的分辨率。
缺少任何样式和viewport meta标签都提示没有任何布局应用于视图。这确实是正在发生的事情。为了理解如何操作,我们必须在Review区域的配方控制器中检查我们的Index操作方法的返回值,该操作负责返回特定配方的评论。此操作方法具有以下返回值:
return PartialView(ToListModel(reviews));
当我们第一次写这个方法的时候,我们似乎做了一个假设,我们只会将这个视图嵌入到另一个视图或布局中。因为视图会嵌入到另一个视图中,所以我们只需要将它作为局部视图返回。当然,这只会返回视图的内容,不会试图将视图嵌入到布局文件中,如_Layout.cshtml或_Layout.Mobile.cshtml。
因为该视图由桌面版本的应用使用,所以我们不想阻止操作返回部分视图。我们也不想仅仅为了给移动浏览器返回一个完整的视图而创建一个新的动作方法。然后,我们的视图需要知道调用哪个动作以及何时调用它。我们需要做的是让控制器负责根据请求者决定使用哪个视图。幸运的是,ASP.NET MVC 4 框架为我们提供了这样一种机制。
动产副
要返回移动站点的完整视图,我们只需确定是否有移动设备发出请求。我们可以使用当前Request的Browser.IsMobileDevice属性来实现:
if (Request.Browser.IsMobileDevice)
{
return View(reviewList);
}
该操作现在将返回移动设备的完整视图,以及模拟桌面浏览器的桌面设备或移动浏览器的部分视图。但是即使返回完整视图也只能解决我们的部分问题。即使我们的工作是返回一个完整的视图,响应看起来和以前没有什么不同。运行时没有与我们的视图相关联的布局。
将_ViewStart.cshtml文件从我们项目的Views文件夹的根目录复制到项目的Areas/Review/Views文件夹。这将通知运行时对此区域使用与我们应用其他区域相同的布局。现在,如果我们在评论区有一些实际的移动视图。
移动视图
我们仍然需要创建特定于移动设备的视图用于查看列表和编辑,但是我们现在可以利用视图命名约定(.Mobile)而不是依赖于视图中的复杂逻辑。在Review区域的Recipe视图文件夹中,复制Index.cshtml并将复制的文件重命名为Index.Mobile.cshtml。以下是我们新的手机评论列表视图的代码:
<h2>
<a href="#"
data-role="button"
data-icon="back"
data-iconpos="notext"
data-inline="true"
onclick="javascript:history.go(-1)">
Back</a>
@ViewBag.RecipeName
</h2>
<ul data-role="listview" data-inset="true">
@foreach (var item in Model)
{
<li>
<p style="white-space: normal;">
@Html.DisplayFor(modelItem => item.Comment)
</p>
<p class="ui-li-count">@item.Rating</p>
</li>
}
</ul>
<p>
@if (Request.IsAuthenticated) {
@Html.ActionLink("Add Review", "Create", new {area = "Review", id = ViewBag.RecipeId, name=ViewBag.RecipeName }, new { data_role = "button" })
}
</p>
注
我们的评级为ui-li-count级。这将显示为计数气泡,我们将很快看到。
我们目前正在返回我们的视图需要的一切,除了食谱的名称。我们的观点是在ViewBag.RecipeName中寻找这个值。我们需要向我们的用户提供这个密钥,因为没有它,我们的用户将没有阅读评论的上下文。也就是说,他们将无法辨别审查属于哪个食谱。这不是桌面版本的审查列表的问题,因为审查与配方详细信息显示在同一页面上。鉴于我们的移动应用并非如此,我们需要解决这个问题。
这样做的第一步是向ViewBag添加一个RecipeName属性。我们可以在为配方审查列表调用的Index操作方法中很容易地做到这一点。填充它会带来问题。
我们不希望审查控制器加载配方,如果他们没有真正的需要这样做,并且,考虑到这种情况,这是没有保证的。如果下游不使用信息,我们也不想开始缓存信息。在这一点上,我们最好的选择似乎是在必要时向评审控制者的行动方法提供配方的名称。我们需要修改Index操作方法,将配方的名称作为可选参数(它是一个字符串,因此默认情况下是可空的,因此是可选的):
public ActionResult Index(int id, string name)
{
var reviews = this
._reviewRepository
.GetReviewsForRecipe(id);
this.ViewBag.RecipeId = id;
this.ViewBag.RecipeName = name;
我们新的操作现在支持通过ViewBag.RecipeName属性检索名称并将名称传递给下游视图。我们现在需要将名称传递给操作方法。这也相当简单。我们只需在构建行动链接时将其添加到路由数据中,如下所示:
@Html.ActionLink(
"View Reviews",
"Index",
new
{
area = "Review",
id = Model.RecipeId,
name=Model.Name
},
new
{
data_role = "button"
})
当选择路由时,添加到操作链接的路由集合中的任何不匹配的数据都将作为查询字符串参数追加到构造的链接中。我们可以通过构建我们的应用并点击配方详细信息页面上的查看评论链接来验证这一工作。如果一切顺利,我们应该看到以下情况:

你知道些什么?成功了。
总结
本章向您介绍了 jQuery Mobile 及其在生成网络应用方面的用途,这些应用的功能就像是设备的原生应用一样。通过渐进式增强,jQuery Mobile 可以抓取任何页面,并将其转化为真正的移动用户体验。
这一章只是触及了 jQuery Mobile 的表面,不可否认的是,它在很少的几页中提供了很多信息。对于一些真正有趣的想法、工具、技术和演示,包括对浏览器定位和滑动事件的支持,我建议您访问位于jquerymobile.com的 jQuery Mobile 网站。
说到只抓表面,决定在这本书里一章一章地放什么信息,是我做过的最困难的事情之一。我想涵盖的其他几个项目根本不适合这种尺寸的书。在下一章中,我们将看看一些没有成功的更有趣的项目,我将为您提供自己实现它们的起点。
十五、读者挑战
在这本书里,我们研究了移动网络的历史,学习了如何开发考虑到移动网络的网络应用,然后将该网络应用转化为移动网络社区的正式成员。从第一章开始,我们已经走了很长一段路,但坦率地说,我们只是开始了使用 ASP.NET MVC 4 开发移动网络应用的旅程。如果我们试图涵盖整个主题,我们会有一本书,大约 300 磅重,需要一个小树林印刷。
此外,如果我们涵盖了所有可以想象到的内容,那么读者们,你们会有什么乐趣呢?我坚信你在做的时候学得最好,我想向你挑战一些事情。
这一章将根据你在这本书里的部分阅读,为你提出一些需要完成的挑战。
全文搜索
我们的网站目前缺乏用户直接搜索菜谱的能力。我们的用户可能想通过风格、成分或简单的名字来找到食谱。如果我们的网站已经被适当地抓取和索引,他们可以去他们最喜欢的搜索引擎,但是为什么不在我们的网站上嵌入搜索引擎的功能呢?
您面临的挑战是使用以下技术之一,并为 BrewHow 添加全文搜索功能。
嵌入式搜索
如果你希望用户能够搜索到你网站的内容,但你又不想亲自经历编写这个功能的麻烦,并且不介意一点联合品牌,那么谷歌和必应都为你提供了免费的解决方案。
搜索框
使用谷歌自定义搜索,你可以在你的网站上放置一个搜索框,用户可以用它来查询你的网站信息。谷歌提供了一个界面,允许你在向用户显示这些结果时定制它们的外观和感觉。当然,免费版本的结果会显示我们已经习惯看到的谷歌广告,但你可以支付少量费用来移除这些广告。
API
谷歌和微软都提供了将搜索引擎功能嵌入到你的网站中的应用编程接口。与谷歌自定义搜索搜索框不同,您需要编写一些脏代码,因为该功能是通过 RESTful HTTP 服务提供的。
与谷歌定制搜索一样,这些服务是免费提供的,象征性使用,但一旦你清除了某个搜索阈值,你将需要为继续使用该服务付费。
Lucene.NET
如果你想把全文搜索嵌入到你的应用中,但又不想通过广告来推广(或者你只是不想花钱让别人帮你做),你可能想看看 Lucene.NET。Lucene.NET 是流行的基于 Java 的搜索引擎 Lucene 的一个端口。和 Lucene 一样,Lucene.NET 也是 Apache 软件基金会产品的一部分,并根据 Apache 许可证获得许可。
Lucene.NET 采用文件和索引的概念。索引保存了可供搜索的文档集合。每个文档都是键值对的集合。索引可以在内存或文件系统中构建和维护。
Lucene.NET 的真正好处是它可以被绑定部署。这意味着无需安装任何软件,只需在部署时将程序集与应用捆绑在一起,您就可以轻松获得全文搜索支持。如果您的主机提供商不支持微软 SQL Server 内置的全文功能,这使得 Lucene.NET 成为一个理想的选择。
SQL Server 全文搜索
在微软栈上操作,你可能倾向于使用 SQL Server 的全文搜索 ( SQL FTS )功能。 SQL FTS 功能特别强大,可以根据变调、语言变体(复数、时态等)或邻近性来搜索内容。
SQL FTS 不是基于文档创建索引,而是将表用作索引源,并基于创建 FTS 索引时指定的一列或多列填充索引。这些列可以是传统的基于文本的列,如 char、varchar 和 nvarchar,也可以是 image、xml 或 varbinary(MAX)类型的列。
SQL FTS 甚至可以实现内容感知。这意味着,如果您将一个 Word 文档放在 varbinary (MAX)列中,SQL FTS 足够聪明,可以解析出文档的内容,并将其包含在根据索引进行的搜索结果中。
使用 SQL FTS 的唯一缺点是找不到支持它的托管提供商。如果您自行托管 SQL Server,则需要获得支持您的使用场景的许可证。
在我们的应用中,还有其他选项可用于执行全文搜索,如果您找到一个更适合您需求的选项,那么您应该使用它。不管技术如何,接受这个挑战,在你的应用中实现全文搜索。
社会化
如果我们创建了我们网站的高级功能列表,我们会看到用户可以登录,创建食谱,提供评论,向他们的库中添加项目,就这样。我们可以为用户和我们的用户做比现在更多的事情。如果我们想要一个真正的社交网站,我们需要与我们的用户互动,让他们彼此互动。
为此,您面临的挑战是让您的用户使用以下一种或多种方法参与酿酒社区。
社交媒体支持
显然,如果我们要社交化 BrewHow,我们需要增加对现有社交媒体的支持。这包括对脸书、推特和谷歌+等社交网站的支持。利用这些社交平台,我们可以通知用户我们网站上的活动,并让他们通知其他人他们的活动。
配方添加
为了向您的用户和您网站的潜在用户告知新的食谱添加,您可以在推特上发布食谱添加,并提供返回您网站的链接来查看食谱。请记住,该网站支持匿名访问,因此任何人都可以阅读目前的食谱。
你也可以选择向脸书或谷歌+发布食谱添加,但请注意,这些媒体渠道不太能接受噪音,在开始时,你可能不想每次有人添加食谱时都向这些服务发布消息。
食谱分享
您的用户可能会想要分享他们已经制作、打算制作、已经创造的食谱,或者只是简单地觉得有趣。允许他们将这些信息分享给现有的社交服务会给你的网站带来流量,并帮助建立食谱库和社区。
你应该允许用户将食谱从网站直接推送到他们的推特账户,使用他们的脸书账户推送到 Like 食谱,或者使用他们的 Google+账户推送到+1 食谱,从而实现食谱的共享。
离线支持
有次,你的用户可能无法获得良好的互联网连接。也许他们在当地的酿酒供应店没有覆盖面。也许他们想在平板电脑上保存食谱,而平板电脑只配备了 WiFi。不管什么原因,你应该支持你的用户离线存储食谱的能力。
存在几种服务来支持网络内容的离线访问。其中最受欢迎的两个,口袋和 Instapaper,可以直接放在你的网站上,让你的用户直接发布食谱给他们选择的离线读者。这些服务还允许用户与朋友共享他们保存的离线版本。如果您提供了适当的布局,您可以使用内容的离线版本将用户带到您的站点。
推送通知
signor 目前用于向查看配方列表的所有用户发布新配方,而不管用户对发布的配方是否感兴趣。虽然这是您应该保留的功能,但如果只通知用户他们可能感兴趣的食谱,它可能对用户更有用。
应该允许用户保留允许他们订阅通知的配置文件。这些通知可能是关于对他们创建的食谱的评论,或者是关于添加某些风格的食谱。无论通知是什么,您都可以使用 SignalR 来模拟开发本地移动应用时可用的推送通知。
当然,用户必须查看网站才能收到这些通知。如果你真的想启用推送通知,你必须开发一个本地应用。
土生土长
有些事情是移动网络应用做不到的(目前)。正是在我们需要运行硬件的能力,不受浏览器限制的情况下,我们必须将我们的应用原生化。
如果您决定将 BrewHow 作为本机应用公开,您将需要向远程调用方提供对您平台的访问。在 ASP.NET MVC 4 中,我们最方便的方法是通过网络应用编程接口。
ASP.NET 网页空气污染指数
ASP.NET 网络应用编程接口框架使得构建 RESTful 服务和通过 HTTP 公开这些服务变得简单。这些服务自动使用与我们在本书前面学习的相同的模型绑定技术,并且可以使用 XML 或 JSON 接受和返回请求。
鉴于 HTTP 无处不在,并且 XML 和 JSON 可以在所有主要的移动平台上解析,ASP.NET 网络应用编程接口非常适合您向外部消费者公开您的平台。当然,您可以选择其他路径来公开您的应用以供使用。
请注意,您将希望保护应用编程接口的安全,以防止恶意用户识别您的端点并试图在您的站点中发现漏洞。而且,如果我是实现这个功能的人,我会确保控制器和服务只是执行实际工作的代码的包装器。你不会想在两个地方维持你的逻辑。
开发原生应用
当开发你的原生应用时,你可以用 Java 编写一个安卓应用,用 Objective-C 编写一个 iOS 应用,希望你有时间和耐心给你的用户提供几乎相同的体验,或者你可以利用现有的工具让你维护一组源代码。目前市场上有几种跨平台开发工具。
PhoneGap 和 Appcelerator
PhoneGap(phonegap.com)和 Appcelerator(appcelerator.com)提供了开发工具和框架,允许您从单个代码库中瞄准多个平台。虽然您必须学习框架本身,但这两种工具都允许您用您已经熟悉的语言开发代码,例如 HTML、CSS 和 JavaScript。
这些框架确实有其利弊,为某些硬件级特性提供了有限的支持。但是对于你想要完成的 90%的工作来说,它们已经足够了。还要记住,这些框架每天都在改进,应该很快就会达到与原生开发工具相当的水平。
如果你想成为本地人,不想学习新的框架、语言或两者兼而有之,还是有希望的。
绢毛虫
Xamarin 在我处理跨平台开发工具的经验中是独一无二的。它为您提供对原生硬件的访问,但要求您拥有或获得底层移动操作系统如何工作的知识。这听起来可能令人生畏,但你必须具备这方面的知识,因为 Xamarin 所做的就是允许你使用 C#瞄准自己选择的移动平台。
现在编写代码,知道 iOS 和 Android 在幕后做什么,这确实意味着你的应用的外壳必须特定于一个平台,但是我们了解到,通过良好的关注点分离,我们的逻辑可以独立于任何呈现机制而存在于外部。Xamarin 将允许你用你熟悉的语言编写你所有的移动端业务逻辑,让你专注于你试图解决的问题。
总结
每当我坐下来研究一个框架的新特性或一个旧的已建立特性的细微差别时,我发现我学到了一些新的东西。作为开发人员,如果我们想保持最新,我们必须接受这个学习过程。这就是为什么我以这一系列挑战来结束这本书。我希望你,读者,继续你的追求,更多地了解你的手艺。这就是我选择写这本书的原因,也是我希望你正在读这本书的原因。
这是一段漫长的旅程,我在本章的导言中已经很好地总结了这段旅程。最后,我希望我能够传达使用 ASP.NET MVC 4 框架开发移动应用有多大的潜力。


浙公网安备 33010602011771号