HTML5-WebSocket-PHP-和-jQuery-实时-Web-应用-全-

HTML5 WebSocket、PHP 和 jQuery 实时 Web 应用(全)

原文:Realtime Web Apps

协议:CC BY-NC-SA 4.0

零、简介

几年前,我参加了一个名为“保持实时性”的会议这是一群深入实时世界的演示者,他们解决的问题是世界上大多数人从未听说过的。

这项技术的力量是惊人的,它已经被使用的地方的数量是相当惊人的。我想知道更多,开始使用它然后。我该如何在我自己的应用中使用这个奇妙的、神奇的新想法呢?

我坐在观众席上,参加了一个实践环节,并立即迷失了方向。一个留着胡子的害羞的小家伙拿着笔记本电脑站在讲台上,对着麦克风喃喃自语,用 Vim 以令人难以置信的速度编写代码。当我发现他正在初始化 socket.io 时,他已经完成了应用的一半。

我的情绪低落了,我开始怀疑这种令人敬畏的技术是否只保留给秘密忍者开发者的精英影子集团。如果我跟不上一个正在教这些东西的人,我怎么能靠自己建造任何东西呢?

如果你曾经问过一个真正聪明的开发人员如何做某事,你可能知道这种感觉:当某人达到一定的聪明水平时,他们有时会忘记如何与我们这些以前没有使用过这种技术的人交谈。这将我们置于一种境地,我们要么挖掘大量复杂的代码、规范和粗糙的文档,要么就放弃。

这本书旨在帮助揭开实时编码的神秘面纱,并使任何具有中等 PHP 和 JavaScript 水平的开发人员都可以使用它。如果你现在就想在真实的项目中使用这些东西,并且不需要知道如何构建 Flash polyfill 或维护 Node.js,这本书很适合你。

我们相信,虽然理论是有趣和必要的,但开发的真正令人兴奋的部分是将它投入使用并看到它变成现实。为此,本书中使用的技术易于设置,不需要您学习新的编程语言或框架;这本书基于当今一些最流行的应用、网站和内容管理系统中使用的相同网络技术。

实时应该属于含咖啡因的大众,所以拿起你的咖啡(或茶),让我们开始吧。在天气变冷之前,您将启动并运行 realtime。

一、什么是实时?

如果你在过去的一两年里一直关注着网络发展的趋势,毫无疑问你已经看到了术语实时被抛来抛去。但是什么是实时呢?它与当前的网络技术有什么不同,为什么我们要费心去使用它?

*为了更好地理解实时意味着什么,以及它如何改变我们所知的互联网,让我们看看它试图解决的问题的历史:我们如何在客户端影响我们的 web 应用的状态,而不需要用户采取任何行动?

媒体的演变

实话实说:说到信息,我们有一种想先听到消息的欲望。这种渴望可以归因于天生的求知欲,第一个知道的人可能会给我们带来的机会,或者仅仅是因为这意味着我们可以成为所有八卦的一方。在某些情况下,比起关心新闻内容,我们甚至更重视第一个得到新闻。(巧合的是,这也是潮人存在的全部原因。)我们首先想知道,这意味着我们想在这个信息可用的时候知道。

9781430246206_Fig01-01.jpg

图 1-1。随着某些类型的信息变得司空见惯,其感知价值往往会降低

这种对保持现状的不懈追求让我们走到了今天:我们不满足于洞穴壁画或手写的大部头作品;印刷机给了我们书和传单,但我们还想要更多;报纸和其他期刊每天早上都给我们提供最新消息,但这些事情都发生在昨天。收音机和电视只能在几小时内给我们提供信息,或者——在天气好的时候——几分钟。

互联网让我们有能力与全球观众分享信息。但是这些信息仍然需要很长时间才能被发现,我们依靠电子邮件和论坛来传播信息。谷歌改变了这一切,让数据更容易被发现。即便如此,其页面索引的速度意味着我们仍然需要等待我们的数据通过搜索被发现。“实时博客”的发明意味着,如果我们知道去哪里找,我们就可以收到频繁的更新,而那些目的地通常都是知名的媒体品牌。

社交媒体加大了赌注,创造了一个全球网络,任何人都可以随时分享新闻。在 2011 年埃及革命等事件中,Twitter 等服务是我们的主要信息来源。然而,第一个实时网络游戏规则的改变者是,有史以来第一次,新信息发布的瞬间也可以通过搜索被发现。这开始证明在互联网上即时获取新信息的价值,增加了用户对“实时内容”的期望,甚至导致知名技术评论员罗伯特·斯考伯(Robert Scoble)质疑“实时网络是否是对谷歌的威胁。” 2

社交媒体平台正在转变为实时交流平台。你一发布状态更新,就会收到一个或多个用户的回复。这种快速、交互式的反馈对我们大多数人来说是非常新鲜的,除了我们这些玩基于 Flash 的游戏的人,他们习惯于只提供相对静态的单用户体验的互联网应用。这一新的多用户交互功能带来了更具吸引力和吸引力的用户体验。

媒体已经从提供延迟和静态的内容发展到具有更丰富、实时和互动的潜力。用户看到了这些体验,他们现在对互联网应用的期望大大提高了。

尽管互联网和社交媒体展示了所有这些即时的满足感,但许多来源仍然没有将我们的新闻作为直播内容提供给我们,或者为我们提供互动和迷人的体验。为什么不呢?

网站,而不是网络应用

互联网传统上用于共享静态内容。一个网站仅仅是属于一个集合的静态实体的结构。一个网站的主要焦点是展示它的内容,而“内容为王” 3 的理念并没有改变多少。即使当我们提出创建“动态内容”的技术时,我们实际上的意思是,我们的服务器现在可以基于一组不同但定义好的参数和值动态地生成静态内容。

我们用来查看互联网上的实体的应用,即 Web 浏览器,自然专注于确保它满足日常需求:下载和呈现 HTML 和图像,并了解如何跟踪链接——这在最初就足够了。

以同样的方式,媒体的形式被推动着发展,我们的网站也是如此。我们希望我们的网站看起来更好,所以我们引入了 CSS。我们希望他们对用户的输入反应更快(你能相信你曾经可以对 DHTML 库收费吗?例如,下拉菜单),于是 JavaScript 出现了(让我们忘记 VBScript 曾经存在过)。这些技术增强了网络浏览器的功能,但主要是让我们增强网站上的页面。

一些先驱超越了静态网站,开始考虑动态 web 应用。对于 web 应用,焦点从服务器转移到了客户端。客户必须做更多的工作;它动态地检索和加载内容,根据用户反馈改变用户界面(UI ),并且 UI 以我们传统上与桌面应用相关联的方式呈现。很少关注页面重载和页面的一般概念。内容也变得不那么基于文本,我们开始在 web 应用中实现更具视觉吸引力和交互性的数据表示。

http hack

随着越来越多的人(我们开发人员是先锋)开始构建 web 应用,对 web 浏览器的需求也在增加。性能成了问题;不仅仅是 web 浏览器应用,还有运行浏览器的机器。那些真正推动 web 技术和 web 应用边界的人也遇到了一个巨大的绊脚石:HTTP。4

HTTP 是一种协议,在这种协议中,客户端发出数据请求并接收响应。然而,一些 web 应用开始要求信息从服务器发送到客户机。所以我们不得不开始入侵!黑客攻击会导致非标准化和复杂的解决方案。将跨 web 浏览器的特性支持状态抛入其中,您可以想象这个问题的一些解决方案的复杂性(我们将在后面讨论其中的一些)。

它采用了 Twitter 和脸书等广受欢迎的解决方案,来证明实时网络技术带来的好处和体验需求。在需求的驱动下,这导致了实时网络技术的巨大进步和可用性。

但是首先:“实时”实际上意味着什么?

术语实时指的是事件发生和我们意识到它之间的及时性。一个事件发生和交付之间的时间测量确实取决于该事件。如果事件是把你的脚放在汽车刹车上,那么你的脚放下和刹车之间的时间必须绝对最小。然而,如果事件是在足球论坛中发送聊天消息,并显示给其他用户,几秒钟不太可能有很大的不同。最终,事件需要在足够短的时间内交付,以便该事件仍然相关;在上下文中仍然有意义。想象一下被扇了一巴掌:在巴掌的冲击和疼痛的记录之间没有延迟。这是实时的。如果有延误,那将会非常混乱。

然而,添加任何实时体验的能力最初都不是那么容易的。但是开发人员并不容易被打败,他们已经想出了聪明的变通办法和“窍门”来解决服务器和客户机之间的通信故障。

image 注意这里已经省略了一些最早的与服务器建立双向通信的方法,因为它们不常被使用。

创建交互式、快速动态网页应用的网页开发技术

随着 JavaScript 开始变得更加流行,开发人员开始利用 XMLHttpRequest 对象 5 异步发送 HTTP 请求,或者不需要重新加载当前页面。这叫做 AJAX ,或者异步 JavaScript 和 XML 。

*这种方法非常适合向 web 应用添加用户触发的功能,因此通常仍然依赖于浏览器中的事件,例如单击,因此在保持内容最新的过程中并没有真正解决任何问题。

投票

在 AJAX 站稳脚跟之后,尝试将浏览器事件从等式中剔除并自动获取新信息的过程是一个短暂的跳跃。开发人员使用类似 JavaScript setInterval()函数的东西设置一个刷新间隔,每隔 n 秒检查一次更新。

9781430246206_Fig01-02.jpg

图 1-2。轮询经常发送 HTTP 请求来检查新信息

为了更好地理解这有多浪费,您可以将这种通信想象成客户端和服务器之间的对话:

CLIENT: Hi! Can I have some data?

SERVER: Sure. Here you go!

[time passes]

CLIENT: Do you have any new data for me?

SERVER: No.

[time passes]

CLIENT: Do you have any new data for me?

SERVER: No.

[time passes]

CLIENT: Do you have any new data for me?

SERVER: No.

[time passes]

CLIENT: Do you have any new data for me?

SERVER: I do! Here you go!

就像现实生活一样,客户机和服务器之间这样的对话既烦人又没什么成效。

尽管这个轮询解决方案绝对是一个开始,但它也有缺点。最值得注意的是,它创建了许多空请求,这对一个应用造成了许多不必要的开销。这种开销可能会妨碍应用的良好扩展:如果一个应用每秒轮询一次新数据,并且 10 万用户同时使用该应用,则每分钟有 600 万个请求。

如果考虑到每个 HTTP 请求的开销——在彼得·吕贝尔斯的测试中,每个请求/响应总计 871 字节6——来回发送大量额外信息只是为了发现服务器上没有发生任何新的事情。

HTTP 长轮询

实时进化链的下一步是 HTTP 长轮询 ,这是在设定的时间段内打开一个 HTTP 请求来监听服务器响应的实践。如果有新数据,服务器会发送并关闭请求;否则,在达到间隔限制后,请求将被关闭,并将打开一个新的请求。

9781430246206_Fig01-03.jpg

图 1-3。HTTP 长轮询使 HTTP 请求在一段时间内保持打开状态,以检查更新

与标准轮询相比,这要高效得多。它节省了开销,减少了应用发送的请求数量。客户端和服务器的对话如下所示:

CLIENT: Hi! Can I have some data?

SERVER: Sure. Here you go!

CLIENT: Thanks! I'm ready for more, if it comes in.

[time passes]

SERVER: I have new data for you! Here you go!

CLIENT: Thanks! I'm ready for more, if it comes in.

好多了。这种方法提供了一种机制,通过这种机制,服务器可以提醒客户端有新数据,而不需要客户端方面的任何动作。

如果需要客户机/服务器双向通信,就可以看出 HTTP 长轮询的一个主要问题。一旦长轮询 HTTP 连接打开,客户机与服务器通信的唯一方法就是发出另一个 HTTP 请求。这可能导致使用双倍的资源:一个用于服务器到客户端的消息,另一个用于客户端到服务器的消息。这种情况的确切影响实际上取决于双向交流的数量;客户机和服务器之间的对话越多,资源消耗就越大。

这种方法的另一个问题是,在长轮询请求之间有一小段时间,客户机上的数据可能与服务器上的数据不同步。只有当连接重新建立后,客户端才能检查是否有新的数据可用。这其中的负面影响确实要看数据,但如果数据是高度时效性的,那肯定不是好事。

HTTP 流

HTTP streaming 非常类似于 HTTP 长轮询,除了当新数据可用时或者在给定的时间间隔连接不会关闭。相反,新数据通过保持打开的现有连接推送。

客户端和服务器之间的对话现在变成如下所示:

CLIENT: Hi! Can I have some data? And please let me know whenever any new data comes along.

SERVER: Sure. Here you go!

[time passes]

SERVER: I have new data for you! Here you go!

[time passes]

SERVER: I have more new data for you! Here you go!

这种解决方案的好处是,客户端和服务器之间的连接是持久的,因此一旦有新数据可用,就可以将其发送到客户端,之后的任何新数据也通过同一连接发送。这确保了服务器和客户端保持同步。

HTTP 流仍然不能提供双向通信,因此存在潜在的资源问题,需要使用第二个连接进行客户端到服务器的通信。

HTTP 流方法的一个大问题是它在不同的浏览器中实现方式的不一致性。在基于 Gecko 的浏览器中,可以使用多部分替换头,指示浏览器用更新的内容替换上次接收的旧内容。在其他浏览器中,这是不可能的,因此响应缓冲区会不断增长,直到没有其他选择,只能关闭并重新打开到服务器的连接。

Web 浏览器中基于 HTTP 的解决方案的其他问题

使用多个连接进行双向通信的要求和跨浏览器实现的差异并不是基于 HTTP 的解决方案的唯一问题。浏览器还限制了来自网页的 HTTP 请求的目的地以及可以建立的连接数。

在网页中运行的 JavaScript 向服务器发出请求的能力长期以来被限制为只允许向同一个域发出请求。 7 例如,如果网页是www.example.com/index.html,JavaScript 只能向www.example.com上的资源发出请求,或者通过操纵 JavaScript 中document.domain的值,可以向任何 example.com 子域发出请求,比如 sub.example.com。这种限制是由浏览器供应商出于安全原因设置的,但与许多安全限制一样,它阻止了向其他域发出请求的合法用例。跨来源资源共享(CORS)解决了提出这些请求的需求。 8 CORS 有很好的浏览器支持, 9 但是有明显的老浏览器考虑。

对可以建立的连接数的限制是针对每个域实施的,例如对www.example.com的请求。在早期的浏览器中,这意味着同一个域只能有两个连接。对于基于 HTTP 的解决方案,这意味着您只能打开使用 HTTP 长轮询或流的 web 应用或网站的一个页面。如果您尝试打开第二个页面,连接将会失败。解决这个问题的方法是将许多子域映射回同一个服务器。连接限制在现代浏览器中仍然是强制的,但是现在允许的连接数要合理得多。 10

术语注释

有许多不同的术语被用来描述基于 HTTP 的实时 web 解决方案。其中大部分都是总括性的术语,涵盖了开发人员用来通过 HTTP 实现服务器到客户端通信的各种方法。

这些术语包括 Comet 、HTTP Server Push 和 AJAX Push 等等。问题是,尽管其中一些术语有非常具体的定义和技术——尤其是 Comet——它们对不同的人有不同的含义。

本书的观点是, Comet 是一个术语,用来定义应用结构中的范例:即模拟使用两个 HTTP 连接的服务器和客户机之间的双向通信。 11

9781430246206_Fig01-04.jpg

图 1-4。Comet 范例意味着客户机和服务器之间的双向通信 11

Comet 应用可以在任何时候向客户端传送数据,而不仅仅是响应用户输入。数据通过之前打开的单个连接传递。

—亚历克斯·罗素

甚至有人认为 HTML5 WebSockets 等新技术是 Comet 范式的一部分,而不是它的替代品。然而,亚历克斯·罗素(他创造了这个术语)现在已经证实,我们应该把 Comet 看作是旧的基于 HTTP 的黑客的总称,并且用一种叫做 WebSockets 的新技术展望未来。 12

Web Sockets 是彗星的一种形式吗?或者彗星只是 HTTP 黑客?我倾向于后一种定义。这句话和那些黑客也许应该一起消失在夕阳下。就我而言,欢迎我们的非 HTTP 实时霸主。在某种程度上,我们可以忘记旧的浏览器,我们都可以使用“网络插座”,不再需要任何特定的保护伞。

—亚历克斯·罗素

解决方案:WebSockets

毫无疑问,你已经听到人们谈论 HTML5 和它所有的新功能。其中两个新特性直接应用于实时 web 技术和客户机服务器通信——这是一个极好的结果,表明 web 标准组织和浏览器供应商确实听取了我们的反馈。

服务器发送的事件和 event source API13是 HTTP 流解决方案的形式化,但还有一个更令人兴奋的解决方案。

你可能听过一两次 WebSockets 这个术语。如果你以前从未真正研究过实时,WebSockets 可能不会出现在你的雷达上,除非它是谈论 HTML5 所有伟大新特性的文章中的一个流行词。WebSockets 如此令人兴奋的原因是,它们提供了一种标准化的方式来实现我们多年来一直试图通过 Comet hacks 实现的目标。这意味着我们现在可以通过单个连接实现客户端服务器双向实时通信。它还内置了对跨域通信的支持

9781430246206_Fig01-05.jpg

图 1-5。Websockets 打开一个全双工连接,允许双向客户端服务器通信

WebSocket 规范是 HTML5 的一部分,这意味着 web 开发者可以在现代浏览器中使用 WebSocket 协议。14

根据 WHATWG, 15 的说法,WebSocket 协议定义了一种在 web 应用中添加实时通信的标准化方式:

WebSocket 协议支持在受控环境中运行不受信任的代码的用户代理与选择加入来自该代码的通信的远程主机之间的双向通信。为此使用的安全模型是 Web 浏览器通常使用的基于原点的安全模型。该协议由初始握手和随后的基本消息成帧组成,分层于 TCP 之上。该技术的目标是为基于浏览器的应用提供一种机制,这种机制需要与服务器进行双向通信,而不依赖于打开多个 HTTP 连接(例如,使用 XMLHttpRequest 或

广泛的 WebSocket 支持带来的最有益的影响之一是可伸缩性:因为 web socket 使用单个 TCP 连接在服务器和客户端之间进行通信,而不是多个单独的 HTTP 请求,所以开销大大减少了。

WebSocket 协议

因为使用 HTTP 无法实现全双工通信,所以 WebSocket 实际上定义了一个全新的协议,或者从客户端连接到服务器的方法。

这是通过打开一个 HTTP 请求,然后请求服务器通过发送以下报头将连接“升级”到 WebSocket 协议来实现的: 17

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin:http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

如果请求成功,服务器将返回如下所示的标头:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

这个交换被称为握手,它是建立 WebSocket 连接所必需的。一旦服务器和客户端之间成功握手,就建立了双向通信通道,客户端和服务器都可以独立地向对方发送数据。

握手后发送的数据被封装在中,这些帧本质上是信息块。每个帧以一个0x00字节开始,以一个0xFF字节结束,这意味着除了消息的大小之外,发送的每个消息只有两个字节的开销。

因此,我们已经明确表示,这对 web 开发人员来说是个好消息。但不幸的是,并非所有的独角兽和冰淇淋甜筒都是如此:和以往一样,我们将等待少数用户和公司升级到现代浏览器。我们还将等待互联网基础设施的某些部分赶上来。例如,一些代理和防火墙会阻止合法的 WebSocket 连接。然而,这并不意味着我们不能在我们的应用中使用它们。

为什么要学习实时网络技术呢?

你可能想知道为什么值得学习这些;这项技术最初可能看起来很复杂,很难支持,很难学习,而且它太新了,不重要。

事实是,实时技术已经改变了我们与网络互动的方式:如前所述,像脸书这样的社交网络正在使用实时组件;Spike TV 与 Loyalize 公司合作,允许《最致命的战士》第四季大结局的观众参与一些现场投票,从而改变了电视节目的进程; 18 谷歌已经在其几个项目中加入了实时功能,包括谷歌文档和谷歌分析。

9781430246206_Fig01-06.jpg

图 1-6。谷歌分析使用实时技术来显示分析数据

如果我们希望作为 web 开发人员跟上时代,我们需要尽早接受实时技术。对我们来说幸运的是,有很多公司致力于从无聊的旧拉动式网络向全新的 holymolyawesome 实时驱动网络转变。我们所要做的就是想出一些很酷的东西用它来建造。

现在在你的应用中使用实时网络技术

虽然你可能无法开始完全依赖 WebSocket 技术来开发你的新网络应用,但是现在有越来越多的公司和项目致力于让你获得实时网络功能。他们的方法不同,从使用(喘气!)Flash 19 ,它实际上已经有了多年的套接字支持,当 WebSockets 本身不能专注于我们前面提到的基于 HTTP 的解决方案时,它可以作为一个后备。

一些选项包括 Socket.io、 20 Faye、 21 SignalR、 22 PubNub、23Realtime.co、 24 和 Pusher 25 (有关更全面的解决方案列表,请参见实时 Web 技术指南)。 26

在本书中,我们将重点介绍 Pusher 的使用。

摘要

实时就是现在正在发生的事情。使用这一功能,我们可以让客户知道新数据可用,而不会产生大量开销,这使我们可以创建应用,在更新信息可用时(而不是在用户请求更新后)为用户提供实时内容体验。更重要的是,它让我们能够构建交互式功能,为我们的应用用户提供更具吸引力的体验。这使得他们会回来买更多。

既然您已经了解了实时的来源、含义、工作方式以及它提供的好处,那么您可以开始选择工具来构建您的第一个实时 web 应用了。在下一章,我们将讨论你将用来构建应用的所有组件技术和编程语言。

1

2

3

4

5

6

7

8

9

10

11 图及引用来源:http://infrequently.org/2006/03/comet-low-latency-data-for-the-browser/

12

13

14

15

16

17 这些例句头都是借用了http://tools.ietf.org/html/rfc6455

18

19

20

21

22

23

24

25

26**

二、工具

在这一章中,你将对你将要构建的应用有一个大致的了解,以便学习如何使用实时网络技术。您将使用这个粗略的想法来确定构建应用所需的工具,并快速浏览每个工具的角色和功能。

在本章结束时,你应该对你已经知道的技术有所更新,并准备开始学习新的知识。

我们在建造什么?

在我们做任何其他事情之前,看一看我们正在试图构建的东西可能是一个好主意。这应该给我们一个应用需要做什么的粗略轮廓,这允许我们创建一个工具列表,我们将需要使这一切发生。

我们在本书中的目标是创建一个问答应用。该应用将允许演示者创建一个“房间”,与会者可以加入。

与会者将能够提出一个问题,该问题将立即显示在演示者的设备上,任何带有浏览器的设备,如笔记本电脑、平板电脑或智能手机,都将得到回答。如果另一个与会者已经问了这个问题,与会者将能够投票选择答案,以向演示者表明哪些问题是最紧迫的。

演示者可以将问题标记为已回答,还可以在演示结束时关闭房间。这些行动的结果将立即显示给所有与会者。

从发展角度来看,这意味着什么?

现在我们知道了应用的基本功能,我们需要将它分成不同的层,这也有助于我们将应用分成不同的技术。

首先,我们需要一个用户界面,这样用户就可以用简单的方式与我们的应用交互。没有出色的用户界面,我们的应用再酷、再有用也没用;如果它很难或令人困惑,它就不会被使用。

其次,我们需要处理用户请求,并处理他们在与应用交互过程中执行的各种操作。为了让应用有用,它需要做一些事情。

第三,我们需要存储用户提供的应用数据,以便房间可以存档,设置可以存储,并且各种其他数据可以在整个应用中保持。

第四,更新需要是即时的。如果用户不得不不断刷新以获取数据,那么问题重叠和错过信息的可能性会高得多。这个应用的有用性几乎完全取决于信息传递的实时性。

最后,我们需要确保认证并开始使用该网站是简单而不费力的。用户可能会在演示开始时第一次使用这个应用,所以他们不会有很多时间来填写个人信息或查看确认电子邮件;我们需要让他们尽可能快地启动并运行应用。

选择我们的工具

现在我们对应用的各个部分有了一个大致的概念,我们可以选择技术来满足每个部分的需求。让我们看看您将用来构建该应用的各种技术,并深入了解每种技术将扮演的角色。

HTML5

HTML5 是在开发界引起巨大轰动的技术之一,因此它的意义已经被营销人员、博客和一般极客媒体严重淡化了。

虽然 HTML5 的发布意味着我们开发网站的方式发生了很多变化和改进,但我们专注于几个关键部分,这些部分将帮助您实现这款应用。

image

我们为什么需要它?

HTML5 将提供一些我们需要的东西,让我们的应用以我们想要的方式工作,即这些:

  • 标记创建我们的应用的用户界面:如果没有标记语言,就很难以一种易于理解的方式向用户呈现应用数据。几乎互联网上的每个网站和 web 应用都使用某种形式的 HTML 来呈现数据,我们的也不例外。
  • WebSockets 允许演示者和出席者之间的实时交互:我们将在本章的后面更详细地讨论这一点。
  • 比以前的 HTML 规范 : 1 更干净、更简单的语法 HTML5 中的新元素——比如<header><section>——使得标记更容易扫描和调试,这减少了维护方面的麻烦,加快了我们的初始开发。
  • ****数据属性,它允许我们轻松地包含额外的数据:它本身并不是特别有用,但是当我们将它与 jQuery 结合使用时,它提供了一个非常简单有效的语法来处理特殊效果和事件。稍后,当您开始使用 jQuery 时,您将了解到更多这方面的内容。
  • 更健壮的表单元素来改善用户界面:最初,HTML 只支持少得可怜的几种输入类型,这意味着开发人员不得不将大多数数据硬塞进带有type="text"<input>中,并依赖客户端和服务器端的验证脚本来确保提供正确的信息。虽然 HTML5 还没有完全解决验证的问题,但它已经为我们提供了许多更有用的输入类型——包括e-mailnumberURL——这改善了一些现代浏览器的用户体验。

起到什么作用?

在我们的应用中,HTML5 将扮演应用骨架的角色。它将为数据和效果提供一个合适的结构。

它是如何工作的?

HTML5 由浏览器解释,浏览器读取 HTML 标签并分配表示样式。这些可以用 CSS 来修改,我们将在接下来讨论。

image 注意因为本书假设了 HTML 的工作知识,所以只解释本书中将要用到的 HTML5 的新特性。如果你需要更多关于 HTML 或 HTML5 的信息,请查阅克里斯托弗·墨菲、理查德·克拉克、奥利·斯图德霍尔姆和迪维娅·马年的《HTML5 的开始》和《?? 的 CSS3》、《??》、《??》和《??》、《??》。

练习 2-1:使用 HTML5 标签创建一个 HTML 文件

下面是一个使用一些新标签的 HTML5 标记的基本例子:

<!doctype html>
<html lang="en">

    <head>
        <meta charset="utf-8" />
        <title>Realtime Web Apps &ndash; Exercise 02-01</title>
    </head>

    <body>

        <header>
            <h1><em>Realtime Web Apps</em> &ndash; Exercise 02-01</h1>
            <p>
                Published on
                <time datetime="2012-05-28T20:26:00-07:00">May 28, 2012</time>.
            </p>
        </header>

        <section>
            <p>
                This is an example HTML file to demonstrate really basic
                HTML5 markup.
            </p>
            <p>
                We're using several of the new HTML5 elements, including
                the <code>&lt;section&gt;</code> and
                <code>&lt;time&gt;</code> elements.
            </p>
        </section>

        <footer>
            <p>All content &copy; 2012 Jason Lengstorf &amp; Phil Leggetter</p>
        </footer>

    </body>

</html>

该代码在浏览器中加载时,将呈现类似于图 2-1 的效果。

9781430246206_Fig02-01.jpg

图 2-1。我们的 HTML5 标记生成的浏览器输出

CSS3

和 HTML5 类似,CSS3 被过度炒作,被淡化了。就其核心而言,CSS3 规范的采用是朝着消除我们对黑客技术和大量图像的依赖以在我们的网站上创建酷效果迈出的一步。它引入了对视觉效果的支持——阴影、圆形边缘、渐变等等——并为开发人员提供了一种使用非标准字体而无需 Flash 或 JavaScript 破解的方式。它提供了选择元素的新方法,并为我们提供了一种无需使用 Flash 或 JavaScript 就能在页面上制作元素动画的方法。

我们为什么需要它?

CSS3 给了我们一些工具来做一些非常酷的效果,这将会改善我们的应用。其中包括:

  • 让用户界面看起来更好的视觉效果:我们是视觉动物,我们倾向于被视觉上吸引人的东西所吸引。您将使用 CSS3,通过使用阴影和渐变等东西,为应用增添一些特色。
  • 改善用户体验的非必要动画:由于还不完全支持,我们不能依赖 CSS3 过渡来制作必要的动画,但我们肯定可以使用它们为现代浏览器上的用户增加一些额外的活力。
  • 告诉浏览器如何显示标记的样式规则:为了给我们的应用一个基本的外观,我们需要创建一个带有规则的样式表,告诉浏览器页面上的每个元素是什么样子。这是 CSS 的首要目的。

起到什么作用?

出于我们的目的,CSS3 将充当视觉层。它将赋予应用“皮肤”,提供我们所追求的精致美感,并创建小的、非必要的效果,以增强那些使用支持它们的浏览器的用户的体验。

它是如何工作的?

CSS 通过文档的head.中的link标签链接到 HTML 文档中,然后由浏览器解析,将样式规则应用于标记中包含的元素。这本书假设了 CSS 的基本知识,所以我们将只讨论你将在应用中使用的 CSS3 的新特性。

练习 2-2:将 CSS 添加到页面

让我们继续练习 2-1 中的代码,并在页面中添加一些 CSS。首先在 HTML 文件所在的文件夹中创建一个名为styles的新文件夹,然后在styles中创建一个名为02.css的新文件。

接下来,在我们在练习 02-01 的<head>部分创建的 HTML 文件中添加一个<link>标签。这将加载我们的 CSS 文件:

<head>
    <meta charset="utf-8" />
    <title>Realtime Web Apps &ndash; Exercise 02-01</title>
    <link rel="stylesheet" href="styles/02.css" />
</head>

The CSS file is empty right now, so let's add a few rules to give our HTML some style. We'll include some CSS3 for extra flair as well:

/*
* Exercise 02-02, Realtime Web Apps
*
* @author Jason Lengstorf <jason@copterlabs.com>
* @author Phil Leggetter <phil@leggetter.co.uk>
*/

html { background: #efefdc; }

body {
    width: 660px;
    margin: 40px auto;
    background: #def;
    border: 2px solid #779;

    /* Creates two shadow effects: outer and inner */
    -webkit-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -moz-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -o-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -ms-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
}

section {
    margin: 20px 30px 10px;
    padding: 20px 20px 10px;
    overflow: hidden;
    background: white;
    border: 1px solid #dfdfef;

    /* Creates two shadow effects: outer and inner */
    -webkit-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    -moz-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    -o-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    -ms-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
}

body,section {
    /* Sets a border radius for every element that needs it */
    -webkit-border-radius: 15px;
    -moz-border-radius: 15px;
    -o-border-radius: 15px;
    -ms-border-radius: 15px;
    border-radius: 15px;
}

footer { margin: 0 0 10px; }

h1 {
    margin: 20px 30px 10px;
    color: #446;
    font: bold 30px/40px georgia, serif;
}

p {
    margin: 0 0 10px;
    font: 15px/20px sans-serif;
    color: #557;
}

h1,p { text-shadow: 1px 1px 1px #88a; }

header p {
    margin: 0;
    padding: 2px 40px;
    border-top: 1px solid #779;
    border-bottom: 1px solid #779;
    color: white;
    font-size: 12px;
    font-style: italic;
    line-height: 20px;
    text-shadow: 1px 1px 1px #779;

    /* Adds a gradient fade */
    background: #889;
    background-image: -webkit-linear-gradient(top, #aac 0%, #88a 100%);
    background-image: -moz-linear-gradient(top, #aac 0%, #88a 100%);
    background-image: -o-linear-gradient(top, #aac 0%, #88a 100%);
    background-image: -ms-linear-gradient(top, #aac 0%, #88a 100%);
    background-image: linear-gradient(top, #aac 0%, #88a 100%);
    background-image: -webkit-gradient(
                            linear,
                            left top,
                            left bottom,
                            color-stop(0, #aac),
                            color-stop(1, #88a)
                        );
}

footer p {
    margin: 0;
    color: #889;
    font: italic 12px/1.67em sans-serif;
    text-align: center;
    text-shadow: 1px 1px 1px white;
}

将这些规则保存在02.css中;然后保存并刷新 HTML 文件以查看规则生效(参见图 2-2 )。为了获得最佳效果,请使用支持 CSS3 效果的最新浏览器,但是样式表可以在任何浏览器中工作,因为 CSS3 只添加了非必要的效果。

9781430246206_Fig02-02.jpg

图 2-2。应用了 CSS 样式表的(略微更新的)HTML 文件

image 注意您可能已经注意到,有几个规则用一个供应商前缀声明了多次(-webkit--moz-等等),这可能看起来有点混乱。因为 CSS3 还没有 100%完成,每个浏览器处理新规则的方式都略有不同。

如果没有办法在浏览器之间进行补偿,这可能会给开发人员带来问题,因此添加了供应商前缀,以允许不同的规则应用于不同的浏览器。希望不久的某一天,每一个新的 CSS3 规则都有一个统一的语法,但是事情仍然在变化,这是不可避免的。

JavaScript 和 jQuery

JavaScript 是一种客户端脚本语言,这意味着它在用户的计算机上执行。这使得它非常适合于一些任务,比如动画页面上的元素、进行动态计算,以及其他各种如果需要页面刷新就会非常不方便的操作。

JavaScript 还允许通过异步调用脚本来执行某些服务器端操作。这项技术通常被称为 AJAX:异步 JavaScript 和 XML。这个术语的 XML 部分的出现是因为请求通常会返回 XML 数据。虽然这不再是常见的用例,但是这个名字已经被记住了。本质上,AJAX 允许加载不同的页面,并使用 JavaScript 将其内容返回到当前页面。加载的页面可以接收来自 AJAX 请求的数据、处理数据、存储数据、检索新数据,并将数据返回给请求数据的脚本。(我们之前在第一章中讨论过这个问题)。

然而,尽管 JavaScript 拥有强大的功能,但它一直是个麻烦的动物,因为它的文档不够优秀,有时语法混乱,并且在其他浏览器上的实现不一致。因此,JavaScript 的学习曲线非常陡峭,任何希望在项目中使用它的开发人员都需要投入大量的时间。

为了应对这种挫折,一些团体和个人开始着手简化 JavaScript,让每个人都能使用它。他们创建了处理常见任务的框架,克服了跨浏览器的麻烦,并为新开发人员提供了良好的文档和支持社区。

起初有几十个这样的框架,包括 MooTools、YUI、Dojo、Prototype 和 jQuery。两者各有优缺点,但是 jQuery 似乎获得了最多的社区支持,这主要是因为它有很好的文档和非常简单的语法。

我们为什么需要它?

改进的 JavaScript 文档、对该技术使用的普遍接受以及语言标准化为使用该语言的开发人员带来了更好的开发体验。然而,在某些情况下,库还是非常有用的。jQuery 将处理任何未解决的跨浏览器不一致问题,并为我们提供执行以下任务所需的工具:

  • 创建动画来显示应用中正在发生的事情:通过动画显示各种动作来显示用户正在发生的事情是增强用户界面的一种很好的方式,它增加了我们正在努力实现的整体完美性。
  • 处理用户事件:当用户与应用交互时——无论是点击、轻击还是滑动——浏览器都会触发一个事件,jQuery 可以检测到该事件并根据用户的动作执行任务。
  • 显示实时事件的结果:当用户执行某些操作时,应用需要向当前与之交互的所有用户显示操作的结果。您将使用 WebSocket 技术和 Pusher 来处理数据发送——我们将很快介绍这一点——但是通过 WebSocket 连接接收到的信息将触发一个事件,就像单击或任何其他用户交互一样。我们将使用 jQuery 来处理这些事件,并根据应用中发生的事情来执行任务。

起到什么作用?

jQuery 将扮演这款应用一半大脑的角色。它会根据用户交互或实时事件注意到应用中的任何变化,并适当地处理这些变化,或者通过制作动画,或者在其他地方(如另一个用户的设备上)发生变化时更新用户。

它是如何工作的?

使用一个由浏览器解析的<script>标签将 JavaScript 加载到 HTML 标记中。本书假设读者具备 jQuery 的基础知识,因此为了简洁起见,很多基础知识将被跳过。

image 注意如果你想学习 jQuery 的基础知识,可以去拿一本 Jason Lengstorf 写的 Pro PHP 和 jQuery 3

练习 2-3:添加一个简单的 JQUERY 效果

为了试验 jQuery,让我们向 HTML 文件添加一个小脚本,它将执行以下操作:

  1. 绑定到所有<code>元素上的悬停事件。
  2. 当鼠标悬停时,它将从用户鼠标悬停的标签中获取文本,并使用该元素的文本内容来标识页面上的其他元素;例如,<code>&lt;time&gt;</code>标识其他<time>元素。
  3. 其他元素的背景将被设置为黄色。

第一步是在 HTML 文件所在的目录下创建一个名为scripts的新文件夹,并在其中创建一个名为03.js的新文件。

接下来,使用<script>标签将 jQuery 和03.js加载到 HTML 文件中,将它们插入到结束标签的上方

</body> tag:</footer>
<script src="[`code.jquery.com/jquery-1.7.2.min.js"></script`](http://code.jquery.com/jquery-1.7.2.min.js"></script)>
<script src="scripts/03.js"></script>
</body>

现在我们需要将代码添加到03.js中。关于代码的更多细节,它将遵循的步骤如下:

  • 为每个<code>标签的hover事件绑定两个函数:一个用于鼠标进入悬停状态,一个用于鼠标退出悬停状态。
  • 使用 jQuery 的.text()方法检测<code>元素中的标记名;使用简单的正则表达式删除任何非字母数字的字符(删除左括号和右括号);并将匹配的字符串转换为一个String来防止错误。
  • 悬停时,找到匹配的元素,使用.data()存储每个元素的原始背景色;然后使用.css()将背景颜色更改为黄色。
  • 悬停结束后,用.data()取回原来的背景色;然后用.css()恢复。
/*
* Exercise 02-03, Realtime Web Apps
*
* @author Jason Lengstorf <jason@copterlabs.com>
* @author Phil Leggetter <phil@leggetter.co.uk>
*/

(function($) {

    // Highlights the element contained in the <code> tag
    $('code').hover(
        function() {
            var elem = $(getElementName(this)),
                bg   = elem.css("background");
            elem.data('bg-orig', bg).css({ "background": "yellow" });
        },
        function() {
            var elem = $(getElementName(this)),
                bg   = elem.data('bg-orig');
            $(elem).css({ "background": bg });
        }
    );

    /**
     * Retrieves the element name contained within a code tag
     */
    function getElementName(element) {
        return String($(element).text().match(/\w+/));
    }

})(jQuery);

保存您的代码;然后重新加载 HTML 并将鼠标放在其中一个<code>标签上以查看结果(如图 2-3 中的所示)。

9781430246206_Fig02-03.jpg

图 2-3。一个简单的 jQuery 效果在 HTML5 标签的名字被悬停时高亮显示。当用户将鼠标悬停在<时间>文本上时,会高亮显示<时间>元素

PHP

PHP 是一种服务器端脚本语言,为处理数据提供了强大的工具。它为开发人员提供了一种在 HTML 标记中构建动态内容的方法,并且已经发展成为互联网上使用最广泛的服务器端脚本语言之一。

PHP 不是我们可以使用的唯一语言,因为有许多语言可以让我们构建 web 应用。许多语言也让我们使用实时网络技术,但有些语言做得比其他语言好。Node.js 与实时 web 技术紧密相关,主要是因为它的事件性质和 socket.io, 4 ,这可能是最著名的实时 web 框架;Ruby 有很多解决方案,最流行的是 FAYE??。NET 有一个相当新的微软支持的解决方案,叫做 SignalR 6 Python 有很多基于 Tornado 框架的解决方案; 7 以此类推。

有趣的是,PHP 应用通常运行在 Apache 上,并不太适合实时 web 技术,因为它们是在考虑 HTTP 和请求响应范例的情况下构建的。它们不是为处理维护大量持久连接甚至高容量轮询而构建的。这实际上给了我们一个使用 Pusher 的很好的理由,作为一种托管服务,Pusher 消除了维护我们实时 web 技术基础设施的潜在痛苦。

我们为什么需要它?

您的应用将利用 PHP 的几个特性来为应用添加功能,例如:

  • 动态生成输出以定制应用的显示:像用户名、当前“房间”的名称和演示者的姓名这样的东西需要动态插入到 HTML 标记中。
  • 连接到 Pusher API 以实现实时通信:我们将在本章的后面讨论这一点,并在第三章中详细讨论。

起到什么作用?

如果 JavaScript/jQuery 是我们应用的半脑,PHP 将扮演另一半的角色。它将处理用户发送到应用的数据,并发送回各种用户请求的处理后的响应。

它是如何工作的?

PHP 是一个预处理器 ,这意味着它在页面呈现之前进行计算和数据操作。这意味着 PHP 代码被嵌入到标记中,服务器读取 PHP 并在将标记传递给浏览器之前用适当的输出替换它。

image 注意这本书假设了 PHP 的工作知识,所以基本概念和语法将不被涵盖。如果你想提高你的 PHP 水平,请阅读 Jason Lengstorf 为绝对初学者编写的PHP 8

练习 2-4:使用 PHP 插入动态内容

让我们通过将当前日期动态插入标记来试验 PHP。为此,我们需要将 HTML 的一个新副本保存为 PHP 文件,我们将其命名为04.php

接下来,让我们在文件的最顶端插入一些 PHP 代码,就在<!doctype>声明的上面:


<?php

    // Set the timezone and generate two formatted date strings
    date_default_timezone_set('US/Pacific');
    $datetime = date('c');
    $date_fmt = date('F d, Y');?>
<!doctype html>

如注释所示,这段代码将时区设置为太平洋时区,然后在变量中存储两个格式化的日期字符串。

现在我们有了变量,让我们通过在标记中插入以下内容将它们插入到<time>标记中:


<header>
    <h1><em>Realtime Web Apps</em> &ndash; Exercise 02-04</h1>
    <p>
        Published on
        <time datetime="
<?php echo $datetime; ?>">
            <?php echo $date_fmt; ?>
        </time>.
    </p>
</header>

保存此文件;然后将其加载到浏览器中,查看显示的当前日期(参见图 2-4 )。

9781430246206_Fig02-04.jpg

图 2-4。使用 PHP,在标记中输出当前日期

MySQLt1 版

MySQL 是一个数据库管理系统——准确地说是一个关系数据库管理系统(RDBMS)——是这个星球上使用最广泛的。它提供了一种易于阅读的语法来存储和检索表中的数据,并允许开发人员创建可以存储用户设置等数据的应用,在我们的应用中,还可以存储房间名称和问题。

我们为什么需要它?

某些数据(如房间名称和提出的问题)需要存储在数据库中,以便日后检索:

  • 会议室的详细信息:会议室名称和会议信息等详细信息,以便演示者日后返回会议室参考。
  • 在每个房间问的问题:这允许新与会者迟到,不会错过任何东西。

起到什么作用?

MySQL 将扮演我们应用的内存角色。它会记住各种房间和问题的详细信息,并在将来需要时做好准备。

它是如何工作的?

MySQL 安装在服务器上,通过创建数据库、数据库中的表和表中的行来工作。

每一行都是一段特定的数据。例如,如果表名为 rooms ,那么每一行都将包含一个房间的信息,比如它的名称、惟一标识符和其他相关数据。

image 注意 MySQL 也涵盖在前面提到的 PHP 绝对初学者中。

练习 MYSQL 的乐趣

因为将 MySQL 与我们的 HTML 标记集成在一起需要太多的设置,所以让我们变得古怪一点,在命令行上使用 MySQL。当然,您需要在您的系统上安装和配置 MySQL。打开终端并连接到 MySQL,用您的 MySQL 用户名替换您的用户名:

mysql -uyour_username -p

应该会提示您输入密码,然后您将连接到 MySQL 服务器。你也可以使用桌面客户端或者 phpMyAdmin 来做这个练习——在 Mac 上我们推荐 SequelPro, 9 ,在 Windows 上我们听说过 Navicat 的优点。 10

一旦您连接到服务器,创建一个数据库来玩,并确保事情按预期工作。姑且称之为awesome_test_db:

CREATE DATABASE awesome_test_db;

这将为您提供以下输出:

Query OK, 1 row affected (0.00 sec)

现在,让我们选择将对其执行查询的数据库:

USE awesome_test_db;

这应该告诉您数据库已经更改,这意味着我们现在可以创建一个表:

CREATE TABLE awesome_things (
    id      INT PRIMARY KEY AUTO_INCREMENT,
    name    VARCHAR(64),
    percent TINYINT
);

准备好表格后,我们可以插入几行;每一行都有一个事物的名称和一个百分比来表示它有多棒。请记住,这只是为了好玩,并证明 MySQL 正在工作:

INSERT INTO awesome_things (name, percent)
VALUES
    ('Wooden sunglasses', 72),
    ('Pabst Blue Ribbon', 85),
    ('Bands no one has heard of', 100),
    ('Vintage clothing', 67);

这将为您提供以下输出:

Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

既然我们知道了什么是令人敬畏的,让我们确保我们知道所有超过 75%令人敬畏的事情:

SELECT CONCAT(name, ': ', percent, '% awesome, which means I'm onboard.')
FROM awesome_things
WHERE percent>75
ORDER BY percent DESC;

使用CONCAT()允许我们将输出组合成一个句子,而不仅仅是查看原始数据。使用WHERE子句,我们可以过滤结果,以便我们只看到超过 75%的精彩行,因为我们想首先看到最精彩的内容,所以我们按照降序中的percent排序,或者从高到低排序。执行时,您将看到以下内容:

+----------------------------------------------------------------------+
| CONCAT(name, ': ', percent, '% awesome, which means I'm onboard.') |
+----------------------------------------------------------------------+
| Bands no one has heard of: 100% awesome, which means I'm onboard.     |
| Pabst Blue Ribbon: 85% awesome, which means I'm onboard.           |
+----------------------------------------------------------------------+
2 rows in set (0.00 sec)

现在您知道什么是最棒的了,您可能想要通过完全删除数据库来销毁证据:

DROP DATABASE awesome_test_db;

这将完全删除数据库,这样您的 MySQL 服务器就不会塞满测试数据。

HTML5 WebSocket 技术和推送器

我们已经谈了一点 WebSocket 和 realtime,但是让我们回顾一下:HTML5 WebSocket 允许应用将数据推送到客户端,而不是要求客户端不断请求新数据。

练习 2-6:试用 WEBSOCKET API

让我们看一下本地 WebSocket API,以了解如何使用它。创建一个包含以下内容的 HTML 文件。该文件包含连接到 WebSocket echo 测试服务的 JavaScript。这意味着您可以测试连接、发送和接收消息。

<!doctype html>
<html lang="en">

    <head>
        <meta charset="utf-8" />
        <title>Trying out the WebSocket API 02-06</title>
    </head>

    <body>

        <script>
            var ws = new WebSocket( 'ws://echo.websocket.org' );

            ws.onopen = function() {
              console.log( 'connected' );
              console.log( '> hello' );
              ws.send( 'hello' );
            };
            ws.onmessage = function( ev ) { console.log( '< ' + ev.data ); };
            ws.onclose = function() { console.log( 'closed' ); };
            ws.onerror = function() { console.log( 'error' ); };
        </script>

    </body>

</html>

如果您在支持 WebSocket 的浏览器中打开此页面,并打开浏览器的 JavaScript 控制台,您将看到以下内容:

connected
> hello
< hello

当 WebSocket 已经连接到服务器,并且已经调用了onopen函数处理程序时,显示connected消息。代码随后记录> hello以表明它将通过 WebSocket 连接使用 WebSocket send函数发送hello到服务器。最后,当服务器回显消息时,调用onmessage函数处理程序,并将< hello记录到控制台。

这演示了如何使用 WebSocket API,并向您展示了它的用处。但是,正如我们在第一章中所提到的,WebSocket API 还没有被所有的浏览器完全支持,我们需要一个后备机制。因此,如果我们必须自己处理浏览器兼容性问题,实现实时应用可能会很麻烦、棘手,而且非常耗时。

幸运的是,对于我们其他人来说,有许多服务已经克服了这些障碍,并创建了从检查 WebSocket 支持开始的 APIs 然后回归检查下一个最好的解决方案,直到他们找到一个工作。结果是强大的实时功能,而没有向后兼容的麻烦。

在这些提供实时服务的公司中,Pusher 以其极其简单的实现方式脱颖而出,为那些没有庞大用户群、优秀文档和有帮助的支持人员的服务提供免费账户。

Pusher 提供了一个 JavaScript 库 11 ,它不仅可以处理旧浏览器的回退,还可以通过其 API 提供自动重新连接和发布/订阅 12 消息传递抽象等功能,这比我们使用原生 WebSocket API 时简单处理一般消息要容易得多。

最后,因为 Pusher 是一个托管服务,它将负责维护数据传输的持久连接,并且可以处理扩展以满足我们的需求。虽然后一点对于我们的示例应用来说可能不是什么大问题,但是在构建生产应用时,这是一个值得考虑的问题。

出于这些原因,我们将在本书中使用 Pusher 来构建我们的实时系统。

我们为什么需要它?

Pusher 将允许您向应用添加实时通知和更新,包括以下内容:

  • 添加新问题时更新所有用户:这意味着当用户添加新问题时,该房间中当前使用该应用的所有用户都会立即收到新问题。
  • 当演示者将问题标记为“已回答”时更新与会者:当演示者回答问题时,将其标记为“已回答”将会立即更新所有与会者的设备,以防止混淆。
  • 当多个与会者希望回答同一个问题时更新演示者:如果多个用户对某个问题的答案感兴趣,他们可以投票支持该问题。演示者将收到一个视觉提示,让他们知道问题很紧迫。
  • 会议室关闭时更新所有与会者:当演示者关闭会议室时,需要更新与会者,以便他们知道不要问任何不会得到回答的问题。

起到什么作用?

Pusher 将扮演应用神经系统的角色:当发生变化时,它会收到通知,并将信息传递给应用的大脑,以便它们可以处理信息。

它是如何工作的?

简而言之,Pusher 提供了一种机制,让客户端“监听”应用的变化。当事情发生时,Pusher 会向所有正在监听的客户发送通知,以便他们能够做出适当的反应。这就是我们前面提到的发布/订阅范例。

第三章专注于更详细的细节,因此我们将跳过这一部分的练习。

oath

与目前讨论的技术不同,OAuth 是一种协议,而不是一种真正的编程语言。这是 2007 年起草的一个概念,旨在解决提供重叠服务的网站带来的问题;想想社交网络如何访问你的地址簿来寻找朋友,或者照片共享网站如何连接到 Twitter,让你的关注者知道你何时发布了新照片。

问题是这样的:当这些服务开始协同工作时,它们要求用户提供用户名和密码来访问服务,这是一个潜在的巨大风险。怎样才能阻止一个可疑的服务出于自己的目的使用那个密码,包括改变你的密码并把你锁在外面?

这是一个大问题。OAuth 基于对许多其他解决问题的尝试的研究,设计了一个解决方案,使用了它认为每个方案的最佳部分。

套用 OAuth 网站上一个很好的比喻: 13

OAuth 就像给某人一辆豪华汽车的代客钥匙。一把代客钥匙只能让汽车行驶几英里;它不允许进入后备箱;它阻止使用汽车车载电脑中的任何存储数据,如地址簿。OAuth 类似于您的在线服务的代客钥匙:您不需要提供密码,并且您可以只允许帐户的某些特权,而不会暴露您的所有信息。

例如,脸书使用 OAuth 对第三方服务进行用户认证。如果您已经登录到脸书,您会看到一个对话框(在脸书的域上),告诉您需要哪些权限,并允许您接受或拒绝请求。特权是分门别类的——例如,阅读某人的时间表不同于查看他们的好友列表——以确保第三方服务只获得他们需要的特权。

这保证了用户的安全,并减少了网络应用的责任。它还为开发人员提供了一个极好的好处:我们可以允许用户使用他们的脸书、Twitter 或其他使用简单 API 的凭证登录我们的应用。

我们为什么需要它?

我们在构建的应用中不需要它,但它会是一个很好的功能,所以如果你想知道如何包含它,我们将它包含在附录 A 中。简而言之,我们将使用 OAuth 来消除构建用户管理系统的需要。这也将极大地减少注册账户所需的时间,而不会减少应用运行所需的信息。

让我们面对现实吧:大多数人在互联网上拥有的账号比他们能记住的还要多。有人使用我们的应用和不使用我们的应用之间的区别可能就像他必须点击多少个按钮才能开始一样简单。

OAuth 提供了一种很好的方式来获得我们需要的一切:

  • 验证这个人确实是真实的:我们可以合理地假设,任何登录到有效的脸书或推特账户的人都是真实的。
  • 收集关于用户的必要数据:对于这个应用,我们只需要一个名字和电子邮件。
  • 降低准入门槛:通过消除创建账户的所有常规步骤,我们可以让用户只需点击两下就能在几秒钟内进入我们的应用。

起到什么作用?

OAuth 将是我们应用的看门人。它将使用第三方服务来验证用户的真实性,并收集应用运行所需的必要信息。

它是如何工作的?

你可以在附录 A 中找到关于 OAuth 细节的更多细节,但是在它的核心,OAuth 联系服务,我们希望通过它来验证我们的用户,并发送一个标识我们应用的令牌。如果用户尚未登录第三方服务,系统会提示用户登录,然后允许或拒绝我们的应用请求的权限。如果用户允许我们的应用访问所请求的数据,服务会发回一个令牌,我们可以使用它来检索必要的数据,并认为用户“登录”了我们的应用。

摘要

此时,我们已经成功地为我们的应用定义了一个粗略的功能和需求列表。我们还利用这些信息充实了一个工具列表,我们将使用这些工具来使这款应用变得更加生动。

在下一章中,您将熟悉 Pusher 及其底层技术,并将构建您的第一个实时应用。

1 这是 100%作者的意见。

2

3

4

6

5

7

8

9

10

11

12

13

三、Pusher

现在,我们已经为应用奠定了一些基础,并回顾了我们将用来构建它的一些编程语言,您可以开始熟悉将要发挥作用的新技术了。

首先,让我们熟悉一下 Pusher,它将负责我们站点上的实时交互。在这一章中,我们将探索 Pusher 的起源,底层技术,他们提供的一些帮助开发的工具,并通过构建一个简单的实时活动跟踪应用来实践。

推杆简史

Pusher 是互联网上一个相对较新的趋势的一部分,这个趋势被称为软件即服务(SAAS) 。这些公司收费向其他开发者提供有用的工具、实用程序、服务或其他价值。这使我们能够使用极其强大的新技术,而无需花费数天或数周时间来解决可扩展性和跨浏览器支持等问题。

2010 年初,联合创始人 Max Williams 和 Damien Tanner 经营着英国最成功的 Ruby On Rails 商店之一。当他们发现需要在团队成员之间同步数据时,他们构建了一个小工具来利用新的 HTML5 WebSocket API。

一旦他们意识到使用他们的基础设施创建实时应用是多么容易,他们就看到了超越内部管理工具的机会。

从那时起,Pusher 已经发展成为实时 SaaS 市场的主导力量,拥有令人印象深刻的客户名单,包括 Groupon、MailChimp 和 SlideShare。

为什么要用 Pusher?

使用像 Pusher 这样的托管服务的一个关键原因是,它通过使以前复杂的目标更容易实现来加速开发过程。其中一个关键部分是达到目标的速度。但是还有其他人。因为我们使用的是 Pusher 托管服务,所以有必要强调一下这样做的一些好处。

可量测性

基于云的 SaaS 首先提供的是可伸缩性的承诺,Pusher 也不例外。它提供并扩展实时基础设施,这样我们就可以专注于为我们的应用添加实时交互功能。

WebSocket、回退支持和自动重新连接

在第一章中,我们展示了 WebSocket 技术有多棒,但是仍然有一个不幸的需求,那就是为旧的浏览器或者复杂的网络提供支持。因此,Pusher 也能处理老浏览器的回退问题,这应该是一种解脱。其 JavaScript 库根据浏览器运行时间和网络条件选择最合适的连接方式。该库还会检测断开的连接,并自动为您重新连接。

该库在用户的浏览器和推送服务之间建立了一个连接,这样一旦有了新数据,就可以发布并推送给用户。您也可以直接从客户端发布信息。在本章的学习过程中,我们将更深入地研究可用的功能。

其他客户端库

尽管 WebSocket 规范现在属于 HTML5 的范畴,但重要的是要记住它是一个协议的规范。这意味着任何可以建立 TCP 连接的技术也可以建立 WebSocket 连接。Pusher 利用了这一点,还提供了许多其他技术的客户端库,包括用于 iOS 开发的 Objective-C、用于 Android 和桌面开发的 Java、用于 Flash 的 ActionScript 和用于 general 的 C#。NET 和 Silverlight 运行时。

应用接口

没有 REST API 的托管服务是不完整的,Pusher 也不例外。1REST API 主要用于发布数据,但也提供在 Pusher 中查询应用状态的功能。提供 REST API 意味着任何可以发出 HTTP 请求的技术都可以使用这个 API。

服务器库

因为 REST API 需要认证,并且有一些潜在的复杂需求,所以开发了许多服务器库来使执行对 API 的请求变得更容易。因为调用 REST API 所需要做的只是发出一个 HTTP 请求,所以可用库的数量非常多也就不足为奇了。 2 最常用的库包括 PHP、Ruby、。NET、Python、Node.js 和 Java。

开发人员工具

托管服务的另一个越来越有用的特性是某种形式的开发人员工具,它增加了开发的便利性。这是通过公开服务可用的内部工作和日志记录信息来实现的。在 Pusher 的例子中,这意味着公开与您正在构建的应用相关的信息,比如连接和数据流。我们将在构建应用时使用这些工具。

文件

文档对于任何技术来说都是必不可少的,对于托管服务来说更是如此,这可以被视为一个黑盒——这不像你可以在托管服务的所有组件的源代码中四处挖掘以了解发生了什么——即使他们开源了其中的一些组件或使用现有的开源组件。

因此,包含大量代码示例的良好文档可以决定是否使用某项服务。Pusher 的文档 3 是用户指南和参考资料的结合,重点在于探索什么是可能的,并让您快速找到实现某个目标的方法。

Pusher 术语

在我们开始使用 Pusher 之前,您应该熟悉一些术语。

一个连接代表一个客户端(在我们的例子中是一个 web 浏览器)和推送服务之间的持久连接。消息通过 Pusher 在此连接上接收,我们也可以通过 Pusher 在此连接上向其他用户发送消息。

Pusher 使用发布-订阅消息模式 4 ,因此使用通道的概念来识别客户端应用感兴趣的内容(例如,“体育新闻”或“新闻推文”)。一个频道简单地用一个名字来表示,你可以通过订阅来注册一个频道。

有些人把实时网络称为“事件网络”这是因为实时 web 技术经常被用于传达某种事件已经发生,并且将与该事件相关联的数据传递给有兴趣知道该事件已经发生的用户或系统。例如,当某人发推文时,系统可能会通知其所有关注者该推文事件,并提供推文数据(推文用户、文本、推文时间、是否是对另一条推文的回复等)。)对他们来说。

因此,Pusher 也使用事件的概念也就不足为奇了。事件与渠道结合使用;当您订阅一个频道时,您可以绑定到该频道上的事件。例如,您可以订阅“新闻-推文”频道,并绑定到“新闻 _ 推文”、“推文 _ 删除”、“推文 _ 收藏”和“转发”事件。事件与 CRUD(创建、读取、更新、销毁)功能的创建、更新和销毁部分配合得非常好;以及反映事件结果的用户界面变化。

为了接收数据,必须发布。数据在通道上发布并与事件相关联。为了维持事件网络的概念,在一个频道上发布数据的行为被称为触发事件。因此,触发发布可以互换使用。

在我们开发应用时,您将看到所有这些概念都在使用,这将演示您如何在新的或现有的应用中轻松使用相同的概念。

Pusher 入门

在我们开始构建示例应用之前,您需要做的第一件事是注册一个 Pusher 沙盒帐户。这是一个免费帐户,它限制了您可以从客户端同时打开的连接数和每天可以发送的邮件数。这个免费帐户对于本书中介绍的所有应用来说都绰绰有余。

首先,前往http://www.pusher.com的推手网站并注册。

注册后,你将被带到你的账户主页,那里有你对你的账户进行快速概念验证所需的所有信息(见图 3-1 )。)

9781430246206_Fig03-01.jpg

图 3-1。Pusher 欢迎屏幕

暂时就这样。说真的。不过,请保持此页打开,因为您将在练习 3-1 中使用它。

练习 3-1:一个简单的推杆测试

为了验证您的帐户是活跃的,并展示使用 Pusher 是多么容易,让我们创建一个极其简单的 HTML 文档并向它发送一条消息。

首先,创建一个简单的 HTML 文件。在内部,添加以下标记:

<!doctype html>
<html lang="en">

    <head>
        <meta charset="utf-8" />
        <title>Realtime Web Apps &ndash; Exercise 3-2</title>
    </head>

    <body>

        <h1>Testing Pusher</h1>
        <p>
            This is a simple demo of how easy it is to integrate Pusher
            into an application.
        </p>

    </body>

</html>

接下来,通过在结束的</body>标签上方插入这个脚本标签,在页面上包含 Pusher JavaScript 库:

<!doctype html>
<html lang="en">

    <head>
        <meta charset="utf-8" />
        <title>Realtime Web Apps &ndash; Exercise 3-2</title>
    </head>

    <body>

        <h1>Testing Pusher</h1>
        <p>
            This is a simple demo of how easy it is to integrate Pusher
            into an application.
        </p>

        <script src="[`js.pusher.com/1.12/pusher.min.js"></script`](http://js.pusher.com/1.12/pusher.min.js"></script)>

    </body>

</html>

接下来要做的是连接到推进器。在下面的代码中,用 Pusher 应用凭证中列出的key替换appKey变量值。

<!doctype html>
<html lang="en">

    <head>
        <meta charset="utf-8" />
        <title>Realtime Web Apps &ndash; Exercise 3-2</title>
    </head>

    <body>

        <h1>Testing Pusher</h1>
        <p>
            This is a simple demo of how easy it is to integrate Pusher
            into an application.
        </p>

        <script src="http://js.pusher.com/1.12/pusher.min.js"></script>
        <script type="text/javascript">
            var appKey =' 079be339124bac43c45c';
            var pusher = new Pusher(appKey);
        </script>

    </body>

</html>

当您创建新的 Pusher 实例时,将建立到 Pusher 服务的新连接。

接下来要做的是检查你是否连接。您可以使用 Pusher 调试控制台手动完成这项工作,这是我们前面提到的开发工具之一。为此,请转到您的应用的 Pusher 仪表板,并单击 Debug Console 链接。现在,在不同的浏览器窗口中,打开新的 HTML 文档。如果你看一下 Pusher 调试控制台,你会看到一个新的连接已经被列出,如图 3-2 中的所示。

9781430246206_Fig03-02.jpg

图 3-2。推杆调试控制台

您还可以通过绑定到pusher.connection对象上的事件来检查您的代码中是否已经建立了连接。 5 如果你想给用户关于连接状态的反馈,或者如果你需要应用在连接不可用时做出不同的反应——如果用户当前离线的话,这可能是有用的。如前所述,Pusher 使用了事件的概念,在监控连接状态时也是如此。在下面的代码中,我们将绑定到state_change事件,并在页面中显示当前状态。实现这一点所需的代码更改以粗体显示:

<!doctype html>
<html lang="en">

  <head>
    <meta charset="utf-8" />
    <title>Realtime Web Apps &ndash; Exercise 3-2</title>
  </head>

  <body>

    <h1>Testing Pusher</h1>

    <div id="connection_state">&dash;</div>

    <p>
        This is a simple demo of how easy it is to integrate Pusher
        into an application.
    </p>

    <script src="http://js.pusher.com/1.12/pusher.min.js"></script>
    <script>
      var pusher = new Pusher( '079be339124bac43c45c' );
      pusher.connection.bind( 'state_change', function( change ) {
        document.getElementById( 'connection_state' ).innerHTML = change.current;
      } );
    </script>

     </body>

</html>

对于每个连接状态变化,调用作为第二个参数传递给bind函数的函数。然后我们可以更新页面来显示当前的连接状态。

现在我们知道了如何检测我们的连接状态,我们可以看看订阅一个通道。如前所述,通道完全由名称来标识:a string。您不需要做任何事情来创建通道或在 Pusher 中提供它。只要订阅它,它就存在了。您甚至不需要担心使用太多的通道,因为它们实际上只是一种将数据从发布者(我们尚未涉及)路由到订阅者的方式。因此,让我们订阅一个频道并绑定到该频道上的一个事件:

<!doctype html>
<html lang="en">

  <head>
    <meta charset="utf-8" />
    <title>Realtime Web Apps &ndash; Exercise 3-2</title>
  </head>

  <body>

    <h1>Testing Pusher</h1>

    <div id="connection_state">&dash;</div>

    <p>
        This is a simple demo of how easy it is to integrate Pusher
        into an application.
    </p>

    <script src="http://js.pusher.com/1.12/pusher.min.js"></script>
    <script>
      var pusher = new Pusher( '079be339124bac43c45c' );
      pusher.connection.bind( 'state_change', function( change ) {
        document.getElementById( 'connection_state' ).innerHTML = change.current;
} );

      var channel = pusher.subscribe( 'test_channel' );
      channel.bind( 'my_event', function( data ) {
        alert( data );
      } );
    </script>
      </body>

</html>

与处理连接事件一样,事件处理程序非常简单:当事件被触发时,正在发送的消息显示在一个警告框中。

在一个浏览器窗口中导航到您的 HTML 文件;在另一个窗口中,转到事件创建者,可以在您的应用的 Pusher 仪表盘中找到。您将看到事件创建者表单。输入与我们刚刚编写的 JavaScript 代码相对应的详细信息;通道名应该是 test_channel,事件名应该是 my_event。在事件数据文本区输入一些文本并点击发送事件按钮(见图 3-3 )。

9781430246206_Fig03-03.jpg

图 3-3。Pusher 事件创建者

image 注意 Pusher 建议你发送 JSON 作为事件数据,他们的库会帮助你做到这一点。出于测试目的,为了让警报显示一些人类可读的信息,我们将只发送文本。

当按下 Send Event 按钮时,您应该从您的测试脚本(如果您仍然打开 Pusher 页面,则为两个)中收到一个类似于图 3-4 中的警告。

9781430246206_Fig03-04.jpg

图 3-4。当事件创建者发送测试事件时,会出现一个警告框

在其最简单的形式中,这是一个工作 Pusher 应用。在下一个练习中,您将看到一个更有用的例子。

使用 Pusher 发送事件

练习 3-1 演示了使用 Pusher 接收事件是多么容易,但是发送事件又如何呢?

得益于它的各种 API 库,Pusher 使得发送事件就像接收事件一样简单。我们将使用 PHP API 库,它位于 GitHub 上的https://github.com/pusher/pusher-php-server

练习 3-2:使用 PUSHER 发布和订阅

为了熟悉 Pusher 的服务器端功能,让我们构建一个简单的消息传递系统。

对于基础,为了节省时间,我们将尽可能多地重用我们在第二章的练习中编写的 HTML 和 CSS。创建一个新的 HTML 文件,并输入以下代码:

<html lang="en">

    <head>
        <meta charset="utf-8" />
        <title>Realtime Web Apps &ndash; Exercise 3-2</title>
        <link rel="stylesheet" href="styles/layout.css" />
    </head>

    <body>

        <header>
            <h1>Send a Message with Pusher!</h1>
        </header>

        <section>
            <form method="post" action="post.php">
                <label>
                    Your Name
                    <input type="text" name="name"
                           placeholder="i.e. John" />
                </label>
                <label>
                    Your Message
                    <input type="text" name="message"
                           id="message" value="Hello world!" />
                </label>
                <input type="submit" class="input-submit" value="Send" />
            </form>
        </section>

        <aside>
            <h2>Received Messages</h2>
            <ul id="messages">
                <li class="no-messages">No messages yet...</li>
            </ul>
        </aside>

        <footer>
            <p>
                All content &copy; 2013 Jason Lengstorf &amp; Phil Leggetter
            </p>
        </footer>

        <script src="http://js.pusher.com/1.12/pusher.min.js"></script>
        <script
            src="http://code.jquery.com/jquery-1.7.2.min.js"></script>
        <script
            src="scripts/init.js"></script>

    </body>

</html>

这段代码创建了一个简单的表单,接受一个名称和一条消息,以及一个无序列表来显示接收到的任何消息。

接下来,我们来添加一些 CSS。创建一个名为 styles 的文件夹,并向其中添加一个名为 layout.css 的新文件。该文件已经链接到我们之前创建的 HTML。将以下代码添加到新的 CSS 文件中:

/*
* Exercise 3-2, Realtime Web Apps
*
* @author Jason Lengstorf <jason@copterlabs.com>
* @author Phil Leggetter <phil@leggetter.co.uk>
*/

html { background: #efefdc; }

body {
    width: 800px;
    margin: 40px auto;
    overflow: hidden;
    background: #def;
    border: 2px solid #779;

    /* Creates two shadow effects: outer and inner */
    -webkit-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -moz-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -o-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -ms-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
}

section,aside {
    float: left;
    margin: 20px 30px 10px;
    padding: 20px 20px 10px;
    overflow: hidden;
    background: white;
    border: 1px solid #dfdfef;

    /* Creates two shadow effects: outer and inner */
    -webkit-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    -moz-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    -o-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    -ms-box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
    box-shadow: inset 0 1px 4px #88a, 0 1px 10px white;
}

section {
    width: 400px;
}

aside {
    width: 226px;
    margin-left: 0;
}

body,section,aside,input {
    /* Sets a border radius for every element that needs it */
    -webkit-border-radius: 15px;
    -moz-border-radius: 15px;
    -o-border-radius: 15px;
    -ms-border-radius: 15px;
    border-radius: 15px;
}

footer { margin: 0 0 10px; }

h1,h2 {
    margin: 20px 30px 10px;
    color: #446;
    font-weight: bold;
    font-family: georgia, serif;
}

h1 {
    font-size: 30px;
    line-height: 40px;
}

h2 {
    font-size: 18px;
}

label,li {
    display: block;
    margin: 0 0 10px;
    font: 15px/20px sans-serif;
    color: #557;
}

h1,label,input,li { text-shadow: 1px 1px 1px #88a; }

label input {
    display: block;
    width: 378px;
    border: 1px solid #dfdfef;
    padding: 4px 10px;
    font-size: 18px;
    line-height: 20px;

    /* Creates an inner shadow */
    -webkit-box-shadow: inset 0 1px 4px #88a;
    -moz-box-shadow: inset 0 1px 4px #88a;
    -o-box-shadow: inset 0 1px 4px #88a;
    -ms-box-shadow: inset 0 1px 4px #88a;
    box-shadow: inset 0 1px 4px #88a;
}

/* These MUST be separate rules to work */
input::-webkit-input-placeholder { color: #aac; text-shadow: none; }
input:-moz-placeholder { color: #aac; text-shadow: none; }
input:-ms-input-placeholder { color: #aac; text-shadow: none; }

input.input-submit {
    padding: 4px 30px 5px;
    background: #446;
    border: 1px solid #88a;
    color: #dfdfef;
    font: bold 18px/20px georgia,serif;
    text-transform: uppercase;

    /* Creates two shadow effects: outer and inner */
    -webkit-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -moz-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -o-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    -ms-box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
    box-shadow: 0 1px 6px #88a, inset 0 -1px 10px white;
}

aside h2,aside ul {
    margin: 0 0 10px;
}

aside ul {
    padding: 10px 0 0;
    border-top: 1px solid #dfdfef;
}

aside li {
    padding: 0 5px 10px;
    border-bottom: 1px solid #dfdfef;
}

footer {
    clear: both;
}

footer p {
    margin: 0;
    color: #889;
    font: italic 12px/1.67em sans-serif;
    text-align: center;
    text-shadow: 1px 1px 1px white;
}

在浏览器中加载你的 HTML 文件,你会看到你的样式标记(见图 3-5 )。

9781430246206_Fig03-05.jpg

图 3-5。将使用 Pusher 发送和接收消息的样式化页面

现在您已经有了一个页面,它的 UI 区域被指定用于发送和接收消息,我们可以开始通过发布和订阅来添加实时功能。查看 HTML,我们知道表单将条目提交到一个名为post.php的文件中;让我们从创建该文件并包含 Pusher PHP 库开始。

https://github.com/pusher/pusher-php-server下载 Pusher PHP 库,并将lib目录复制到保存 HTML 文件的同一个文件夹。

保存后,我们可以创建一个新的 Pusher 对象,并开始用几行简短的代码向post.php发送数据:

<?php

ini_set('display_errors', 1);
error_reporting(E_ALL);

require_once 'lib/Pusher.php';

// Make sure you grab your own Pusher app credentials!
$key    = '1507a86011e47d3d00ad';
$secret = 'badd14bcd1905e47b370';
$app_id = '22052';
$pusher = new Pusher($key, $secret, $app_id);

if (isset($_POST['name']) && isset($_POST['message']))
{
    $data = array(
            'name' => htmlentities(strip_tags($_POST['name'])),
            'msg'  => htmlentities(strip_tags($_POST['message'])),
);

    $pusher->trigger('exercise-3-2', 'send-message', $data);
}

前两行打开错误报告以使调试更容易(您可以在生产站点中关闭它)。接下来,我们包括 Pusher PHP 库,定义应用凭证(不要忘记插入您自己的凭证)并实例化一个新的Pusher对象,存储在$pusher中。

接下来,脚本检查两个必需的表单值,namemessage,如果它们确实存在,就将它们存储在一个数组中。然后使用trigger方法将数组传递给 Pusher,这将触发我们命名为exercise-3-2的通道上的send-message事件。传递给trigger方法的事件数据将作为 JSON 发送,由库为我们处理。

此时,提交表单将导致 Pusher 发送一个事件,但是在我们看到应用中的效果之前,我们需要使用 JavaScript 添加一个事件处理程序。但首先,我们至少可以使用 Pusher 调试控制台手动检查事件是否被触发(参见图 3-6 )。

9781430246206_Fig03-06.jpg

图 3-6。触发事件作为 API 消息出现在 Pusher 调试控制台中

为了在我们的应用中可视化这个消息,我们需要编写 JavaScript 来连接 Pusher、订阅一个通道并绑定到一个事件。

我们的 HTML 文件包含一个名为init.js的脚本。因此,让我们在一个名为scripts.的目录中创建该文件,我们将添加两个代码块:一个将侦听由我们的服务器端脚本触发的事件,另一个将捕获表单提交并将它们发送到post.php而不刷新页面。首先,让我们为自定义推送器事件添加一个事件监听器:

(function($){

    // Handles receiving messages
    var pusher  = new Pusher('1507a86011e47d3d00ad'),
        channel = pusher.subscribe('exercise-3-2);

    // Adds an event listener for the custom event triggered by Pusher
    channel
        .bind(
            'send-message',
            function(data) {
                var cont  = $('#messages');

                // Removes the placeholder LI if it's present
                cont.find('.no-messages').remove();

                // Adds the new message to the page
                $('<li>')
                    .html('<strong>'+data.name+':</strong> '+data.msg)
                    .appendTo(cont);
}
);

    // TODO: Handle form submission

})(jQuery);

这使用我们的应用 API 键创建了一个新的Pusher对象—(同样,不要忘记使用您自己的键),然后订阅我们在服务器端使用的Exercise 3-2)通道。

接下来,我们将一个事件处理程序绑定到通道来捕获send-message事件。当它被触发时,我们获取无序的消息显示列表,确保移除占位符消息(如果它存在的话),然后将新消息追加到列表的底部。

为了防止页面重新加载,这将从我们的应用中删除 JavaScript 从我们的浏览器添加的任何消息,我们需要添加第二个代码块来捕获表单提交,并通过 AJAX 而不是使用页面刷新来发布它们。通过插入粗体代码来添加:

(function($){

    // Handles receiving messages
    var pusher  = new Pusher('1507a86011e47d3d00ad'),
        channel = pusher.subscribe('exercise-3-2);

    // Adds an event listener for the custom event triggered by Pusher
    channel
        .bind(
            'send-message',
            function(data) {
                var cont  = $('#messages');

                // Removes the placeholder LI if it's present
                cont.find('.no-messages').remove();

                // Adds the new message to the page
                $('<li>')
                    .html('<strong>'+data.name+':</strong> '+data.msg)
                    .appendTo(cont);
}
);

    // Handles form submission
    $('form').submit(function(){
        // Posts the form data so it can be sent to other browsers
        $.post('post.php', $(this).serialize());

        // Empties the input
        $('#message').val('').focus();

        // Prevents the default form submission
        return false;
    });

})(jQuery);

这段代码捕获submit事件,将序列化的表单数据发送到post.php,并清空消息输入。它保持姓名输入不变,使重复的信息更容易发送;为此,它还将焦点放回到消息输入上。

通过返回false,默认表单提交被阻止,这阻止了页面的重新加载。

现在您已经准备好测试这个应用了。在浏览器中加载您的 HTML 文件并发送一两条消息;消息如预期的那样显示在右侧的列表中。但这还不是最激动人心的部分。

要了解实时的威力,请在两个不同的浏览器(或同一浏览器的两个窗口)中加载这个测试,并开始发送一些消息。没有轮询和页面刷新,你会看到一个窗口中的事件影响了另一个窗口的显示(见图 3-7 )。这是实时的。

9781430246206_Fig03-07.jpg

图 3-7。在一个浏览器中发布的消息会实时显示在另一个浏览器中

调试您的 Pusher 应用

作为一名开发人员,你会知道事情并不总是按照计划进行。当这种情况发生时,您需要开始调试您的应用,尝试并找出为什么您期望发生的事情没有发生!

如果你发现自己处于这种情况,开发者工具是你最好的朋友。幸运的是,所有主流浏览器供应商现在都为您提供了一套很好的工具,让您可以访问 JavaScript 控制台、动态执行代码、调试运行中的代码、检查网页中的元素等等。托管服务也提供了良好的开发工具,让您可以看到服务内部发生了什么,并执行一些手动测试。我们在前面的 Pusher 调试控制台和事件创建器中已经看到了这一点。

无论你是使用 Pusher 还是其他实时网络技术,浏览器开发工具都是你的开发者宝库中必不可少的一部分。使用我们已经讨论过的功能,比如使用console.log来跟踪代码的哪些部分被调用,检查变量值是简单的第一步,使用浏览器开发工具断点或debugger语句来中断执行代码也非常方便。

如果您正在使用第三方库,就像我们在这里使用的 Pusher JavaScript 库一样,库必须公开一种跟踪它正在做什么的方法。Pusher 库通过将一个Pusher.log属性作为其 API 的一部分来实现这一点。作为最后一个练习,让我们看看如何使用它——在构建应用时,它可能会派上用场。

练习 3-3:推进式测井

与大多数 Pusher 功能一样,在 Pusher JavaScript 库中公开日志非常容易。您所需要做的就是给Pusher.log属性分配一个函数。将下面的代码添加到init.js文件中:

Pusher.log = function( msg ) {
    if( console && console.log ) {
        console.log( msg );
}
};

如果您现在在浏览器中导航到 HTML 文件并打开您的 JavaScript 控制台,您将看到 Pusher JavaScript 库记录信息,这在开发过程中非常有用(参见图 3-8 )。与大多数开发日志记录一样,在将应用投入生产之前,应该考虑删除这种日志记录功能。

9781430246206_Fig03-08.jpg

图 3-8。记录 Pusher JavaScript 库的内部工作

摘要

在大约 60 行完整的注释代码中,您已经在 web 应用中实现了一个基本的实时层。当你使用 Pusher 时,真的很简单。

在下一章中,我们将更详细地计划我们将要构建的应用,从我们为什么选择 web 而不是 native 开始。

REST 纯粹主义者可能会认为 Pusher 的 REST API 不是严格 RESTful 的。我们可以将它列为 Web 或 HTTP API。

2

3

4

5

四、选择网络应用而不是本地应用

对于任何梦想创建新应用的开发人员来说,首先必须考虑的一个问题是平台的选择:这个应用应该基于网络并考虑移动应用还是只支持 iPhone?是否有受众支持黑莓版本的应用,或者大多数人只会在笔记本电脑上使用这款应用?

本章旨在涵盖你在回答“web 应用还是原生应用?”时应该考虑的一些因素问题。然后,我们将在问答应用的上下文中回答这个问题,该应用将在本书的其余部分中构建。

为什么重要?

乍一看,这似乎是一个微不足道的决定,不值得思考。然而,这个决定将会影响一切,从你的应用如何编码(很明显),到你如何赚钱(不太明显),到如何维护。

在接下来的几页中,我们将探讨 web 和原生应用的优缺点,然后在我们将要构建的应用的上下文中应用两者的优缺点,这将帮助我们做出明智的决定,即哪种方法最适合我们的情况。

image 注意每个 app 都不一样,大多数想法都有不止一种有效的执行方法。本章中的决定和观点是作者积累的经验、偏见和偏好的结果,虽然它们可能不完全符合您自己的想法,但它们是创建简单有效的应用的一种方式。

要考虑的因素

根据您正在构建的内容,在决定您的应用应该使用本地技术还是 web 技术时,您需要考虑许多因素。然而,有些因素可以普遍适用于所有应用。我们将在这里讨论其中的一些。

了解你的用户

如果你正在开发一个应用,你真的需要确保你在开发的时候考虑到了你的目标用户。知道谁是你的用户会让你更好地理解你的应用需要运行的平台,平台可以公开的特性类型,以及你有潜力提供给你的用户的功能。用户的类型也可能设置功能期望的级别,但是我们在这里不包括人物角色 1

例如,如果您的用户极有可能拥有一部新的智能手机,您可以开始考虑使用更新的技术,并使用设备上的加速计或摄像头等高级功能。这也意味着你有潜力构建运行在现代移动浏览器上的丰富的交互式网络应用,以及本地的 iOS 和 Android 应用。

反过来,了解你的用户也将帮助你识别你真的不应该关注的事情;如果你知道你所有的用户都有一个黑莓手机,那么为 iPhone 开发一个应用就没有意义了(这真的不言而喻,但我们这么说只是为了说明这一点)。

同样值得考虑的是用户可能会在哪里使用您的应用。你可能有一个创新的新控制系统的计划,在这个系统中,用户通过设备摄像头监控的一系列星形跳跃与应用进行交互。但如果你的目标用户可能想在空间有限的火车上使用该应用,这不太可能是可行的。至少,他们会收到其他乘客很多奇怪的目光。

类似地,如果用户在一个连接受限的地方,应用就不应该依赖于从托管的 API 中检索数据。

营销

如果没有人会使用它,那么构建一个功能丰富的应用是没有意义的。因此,让潜在客户了解你的应用对它的成功至关重要。

使用 marketplace 代表着一个确保你的应用——你的产品——至少是可被发现的绝佳机会。在 iOS 设备的 App Store 和 Android 设备的 Google Play 上有超过 50 万个移动应用,这清楚地证明了市场是发现你的应用的好地方。微软紧随其后推出了 Windows 商店。但是,你当然可以争辩说,竞争如此激烈,实际上由于应用的大量选择,找到你的应用非常困难。

image 苹果重新定义了应用的含义:从简单地代表一个在某些设备上运行的程序,到一个拥有蓬勃发展的生态系统的整体概念,包括小额支付,当他们创造了应用商店的概念。产品营销、销售和分销的市场:应用。这是一个惊人的营销成就。

除了可以被发现之外,出现在市场中意味着你的应用有机会从市场内审查系统中受益。理论是,如果你开发了一个很棒的应用,你会收到很好的评论,并从应用排行榜的飙升中受益。

Marketplaces 最初是为本地应用创建的,事实证明非常成功。这个想法被复制到网络应用中,但成功的几率要小得多。有人在努力改变这一点,比如谷歌 Chrome 网络商店、2Firefox market place、 3 脸书应用中心、 4 甚至是苹果的“网络应用”目录、 5 但这种吸收比他们的原生应用要慢得多。

然而,建立和控制的市场也有不利的一面。苹果因其对 App Store 中什么可以什么不可以的奇怪且有时完全独裁的控制而臭名昭著,这对应用开发者来说可能是一个巨大的路障。苹果的审批流程给应用开发者带来问题的最著名案例之一是谷歌语音,它在向应用商店提交其 iOS 应用时被拒绝 6

对谷歌来说幸运的是,网络应用可以绕过苹果的审批程序,允许用户从任何装有 HTML5 浏览器(iPhone 有)的设备上访问它们。谷歌把它的语音服务变成了一个网络应用,而苹果无法阻止它向用户提供这项服务7

image 注意苹果已经让步,现在允许谷歌语音在应用商店发布其应用。 8

长期以来,产品成败的关键因素之一是强大的在线存在。由于网络应用存在于网上,它们自然会存在,并且可以通过自然搜索从搜索引擎的发现中受益。相比之下,本地应用必须创建一个全新的营销网站,或者简单地依赖 marketplace web 列表作为在线表示和自然发现的来源。

销售

一旦你的营销发挥了它的魔力,潜在客户即将转化为真正的用户,你就需要一个销售机制。即使你正在放弃你的申请,仍然有一个销售方面;用户需要承诺以某种方式使用你的应用(例如,注册或下载)。

这里首先要考虑的是信任。如果你已经创建了一个本地应用,并通过一个可信的市场进行销售——由一个大品牌如苹果、谷歌或微软支持——用户已经放心了,该应用已经作为市场提交过程的一部分被审查。此外,他们为应用进行的任何支付都是通过市场进行的,而不是直接通过应用或其他第三方支付系统。

web 应用不会受益于这种继承的信任。它需要通过看起来专业来建立信任;驻留在加密(SSL)连接上;并通过可信、可靠和专业的机制提供支付。消费者对在手机上输入信用卡信息犹豫不决,这种犹豫不是没有根据的:在触摸屏上填写表格并不容易,更难的是在填写时掏出信用卡。再加上有人可能在公共场合使用手机,输入财务信息就更没吸引力了。

因此,处理交易和安全存储支付细节的第三方支付提供商(如 PayPal、Square、Dwolla、GoCardless 等)发现他们的服务需求越来越大也就不足为奇了。尽管与这样的支付提供商集成变得越来越容易,但仍然需要额外的开发人员努力。

然而,本地应用通过各自的市场拥有内置的商业平台,允许预先定价购买应用、应用内购买、付费升级等。

这并不是说没有很多其他的方法来赚钱,无论是本地的还是非本地的,但这种讨论超出了本书的范围。

分配

付费或免费注册完成后,客户现在需要访问您的应用才能实际使用它。

原生应用市场提供了一个屡试不爽的下载机制,这也增加了实际购买东西的感觉。应用下载、安装、启动和运行速度非常快。

但不可否认的是,互联网正变得越来越适用于所有设备,而且这一趋势没有停止的迹象。因此,市场上的设备越来越多(从 iPhones 和 iPads 到亚马逊 Kindle Fire 运行 Android、BlackBerry、Windows Phone 等的智能手机),这些智能手机的现代网络浏览器能够完成桌面浏览器所能完成的大部分工作。这为开发人员提供了一个独特的机会,否则他们需要创建许多本地应用来提供无处不在的支持,只需为所有设备创建一个 web 应用,通过导航到 web 浏览器中的 URL 即可立即使用。

在应用更新时,Web 应用提供了一个明显的优势。你不用等着用户花时间把你的应用更新到最新版本,你只需要在你的服务器上更新 web 应用,用户下次在线加载你的应用时就会得到新的版本。这是一个巨大的好处,特别是对于感受用户如何与一个新的应用交互;随着新功能的推出和旧错误的消除,最新和最棒的应用版本可以立即提供给用户群。

从用户的角度来看,通过支付升级费用,或者通过手动或自动升级,通过市场机制,可以相对容易地应用对原生应用的更新。然而,开发者必须向市场提交更新,并等待验证过程完成。这意味着简单或紧急的应用升级可能需要一段时间才能分发。

外观、感觉和性能

当开发一个本地应用时,该平台的外观和感觉经常被理解和很好地定义。虽然有 web 应用用户界面(UI)框架可用,但它们不会完全符合运行它们的平台的原生感觉。此外,每个平台的 UI 可能不同,这意味着在 web 应用中检测到这一点,并为每个平台维护不同的皮肤。即便如此,你也无法从一个 web 应用中控制浏览器 chrome,因此它永远不会与原生应用的外观完全一致。

但是,重要的是要记住,要试着去拥抱网络这个平台,并利用网络的优势。

这是一个笼统的说法,当然也有例外,但因为本地应用通常是基于设备的本地语言构建的,所以它们通常会感觉更流畅一些。

从启动本地应用的过程开始:在本地应用中,你找到应用的图标,点击它,它就启动了。对于 web 来说,这是不同的,有点复杂;首先打开浏览器应用,然后导航到一个 URL 或打开一个书签。如果你正在使用多个网络应用,那么你也需要在标签之间切换应用。这导致用户使用 web 应用时的体验不如使用本地应用时那么流畅。

因为 web 应用不是本地运行的,所以跳转、停顿、停顿和其他故障的风险更高,这些故障往往会降低用户体验的质量。这并不是说您不会在原生应用中实现这一点,但是当在浏览器应用(它本身运行在操作系统上)中运行 web 应用时,有一个额外的层有可能导致问题。

由于这个原因,web 应用在不降低性能的情况下可以添加的华丽的附加功能的数量上更加有限。

浏览器供应商、HTML5 框架、 9 和初创公司 10 已经取得了一些重大进展,以应对这种性能冲击,情况只会有所改善。但是,在某些情况下,如密集型游戏,原生仍将是最好的解决方案。

发展

尽管我们已经触及了一些影响开发人员的因素,但是我们应该更多地关注那些能够影响开发的因素。因此,当考虑“web v native”时,让我们更深入地挖掘一些真正影响开发应用的因素

语言和平台

HTML5 正在成为跨平台开发技术的首选。尽管不是严格意义上的语言,HTML5 包括用于视图标记的 HTML、用于视图样式和动画的 CSS 以及用于应用逻辑的 JavaScript。如果你使用其中的一种,你很可能也会使用其他的。

如前所述,web 应用可以在任何装有支持该应用所用技术的 web 浏览器的设备上使用。今天的大多数智能手机都有支持 HTML5、CSS3 和 JavaScript 的 web 浏览器。最重要的是,市场上“傻瓜手机”的比例正在迅速下降(此外,傻瓜手机也不倾向于提供特别有趣的本地应用,所以它们是一个有争议的问题)。

当开发移动或平板电脑的 web 应用时,而不仅仅是桌面,唯一增加的负担是考虑移动设备呈现的新屏幕尺寸。大多数网页设计的常见技术,如 HTML5 和 CSS3,都受到大多数移动设备的支持,这意味着在旧版本的 Internet Explorer 上运行应用的所有功能可能比在移动浏览器上运行更困难。

软件开发中一个非常强有力的原则由首字母缩略词 DRY 代表,它是“不要重复自己”的缩写。通过专注于构建一个单一的响应式网络应用将所有支持的平台整合到一个集中的代码库,这是向干式应用迈出的一大步。

有 PhoneGap 11 和 Trigger.io 12 等平台旨在将一个 web app 做成各种平台专用的 app。诸如此类的工具的存在证明了本地应用的市场和学习如何为不同平台编码的困难。微软甚至创建了一个名为 WinRT, 13 的应用架构,允许应用在 HTML5 中开发,使原生 Windows 8 应用和 web 应用使用相同的代码库成为可能。

如果你决定专注于构建本地应用,你很可能会专注于 iOS 设备的 Objective-C,Android 的 Java,以及 Windows 设备的 WinRT 支持语言。学习一门新的编程语言是一件很棒的事情,但是对于一个不知道为给定平台选择哪种语言的开发人员来说,这确实会增加几天甚至几周的开发时间。

如果您的原生应用是为一个被另一个平台取代的平台开发的,您别无选择,只能为新系统重新构建应用。比如,任何为 Symbian 开发的应用都在迅速变得无关紧要,任何 Windows Phone 7 应用都无法升级到 Windows Phone 8;它们必须被重建。

这带来了风险,因为原生应用受其构建平台的影响很大。如果由于一些反常的事情发生,苹果突然关闭了应用商店,开发者将没有追索权或快速修复;他们将不得不重新开始,为另一个平台开发他们的应用,或者干脆放弃,去做别的事情。

维护

维护一个本机应用可能与维护一个 web 应用差不多(甚至更简单,取决于平台)。当面对多平台应用时,维护问题就出现了:如果一个应用需要在 iOS、Android、黑莓和 Windows Phone 上运行,它就会变成四个。突然之间,大多数维护都乘以 4,这甚至没有考虑基于网络的配套工具或应用的 web 应用版本的强烈可能性。

最重要的是,更新必须由用户下载,所以除了市场提示、你的鼓励和支持文档中的注释,没有什么可以保证用户能够获得最新版本的应用。

对于 web 应用,无需对该应用的 Android、iOS、BlackBerry 和 Windows Phone 版本进行修复,只需更新一个版本的代码(假设该应用是为响应而构建的)即可清理该应用设计的任何设备上的问题。

测试

当创建一个 web 应用时,努力创建一个“一刀切”的应用。因此,它需要对所有需要支持的设备进行认真的测试。理想情况下,它甚至应该跨每个平台的不同版本进行测试。

原生应用提出了与测试网站跨浏览器支持类似的问题:iPhone 的修复可能会在 Android 上引入新的错误,一个版本的 Android 的变化可能会在另一个版本的 Android 上产生意想不到的副作用,并且总有可能某个功能不能在每个设备上以相同的方式工作。

功能支持

如果你的应用需要访问设备的加速度计或摄像头,那么争论就结束了,除非你确切知道你的用户将在他们的手机上使用什么浏览器。例如,要从浏览器访问设备上的网络摄像头,你需要使用getUserMedia API,即使是主流浏览器的最新版本也不支持它; 14 一如既往,Chrome、Firefox、Opera 一路领先。

如果你不知道你的 web 应用的所有用户是否都支持它需要的所有特性,你最好的选择是创建一个本地应用。

操作系统向本机应用公开对大多数硬件功能的访问。浏览器正在慢慢地公开对这些特性的访问,但是它们仍然不能跨浏览器使用。一个这样的例子是方向控制。在某些应用中,您可能总是希望用户以横向模式查看,例如,一个赛车游戏,但是尽管有检测方向初始状态和变化的方法,但目前在 web 浏览器中没有 API 来控制这一点,所以唯一的解决方案是有效的黑客攻击。 十五

现代移动 web 浏览器在不断发展,提供 API 来访问硬件和其他操作系统功能的浏览器的数量也在不断增加。但是现在,本机应用具有优势,因为它提供了对应用可以访问的硬件和操作系统功能的完全控制。

连通性

我们生活在一个联系越来越紧密的世界,所以很多应用依赖于连接也就不足为奇了。

为了最初获取我们的本地或 web 应用,我们需要一个互联网连接。应用提供的许多功能也需要连接,例如任何利用其他互联网服务或 API 的功能(例如:公共交通应用,在待办事项或笔记应用(如 EverNote)等设备之间同步信息的功能,或地图应用,这些应用绝不能存储用户可能请求的所有信息)。

其中一些应用,如 EverNote,在离线时仍然可以提供有用的功能。但其他公司,如 Skype,没有连接就无法提供任何核心功能。他们还不如拒绝开门。

一旦本机应用下载到您的设备上,所有资源(至少在大多数情况下)现在都存储在设备上,这意味着它应该可以离线使用。但是许多应用假定连接性会导致非常令人沮丧的用户体验。

这种情况没有真正的原因,web 应用也是如此。HTML5 有定义良好的离线支持, 16 包括应用文件缓存和本地存储,所以在开发人员的关心下,有一个工作的离线 web 应用是可能的,就像本地应用应该离线工作一样。

根据需求选择

鉴于本地应用和网络应用各有利弊,很明显,在为应用选择平台时,没有明确的正确答案。有太多的因素影响了这个或那个仅仅是“更好”的决定。

相反,应用开发人员应该花合理的时间考虑他们的应用,它将如何使用,它将做什么,以及他们打算如何赚钱。在他们弄清楚需求后,他们可以通过前面列出的利弊来运行他们的应用,以及针对他们的情况的任何其他考虑,并选择最有利于他们的应用的方法。

选择网络应用而非本地应用

对于您的应用项目,让我们根据之前的利弊来权衡应用的拟议功能,以针对这种情况做出最佳决定。

这个应用是做什么的?

虽然我们将在下一章中更深入地了解这一点,但这个应用提供了一个简单的界面,让观众可以向演示者提问,演示者也可以实时接收这些问题。

这个 App 是怎么赚钱的?

出于本书的目的,该应用不赚钱,但如果它将被货币化,不太可能有人会愿意支付 0.99 美元来问一个他们可以简单举手的问题。

向演示者收费来创建一个房间可能更有意义,因为该应用可以帮助他们控制房间,并确保所有问题都得到回答。主持人可能会在布置房间之前购买房间,这在桌面浏览器上最容易做到,因此通过网络界面付款可能不会成为一个障碍。

人们将如何使用这个应用?

据推测,用户将在演示者举办活动的物理空间中。这可能是一所大学的教室,一个会议的会议室,或者一个大公司的主题演讲的会议室。

因为与会者很可能不在办公桌前,所以可以安全地假设她可能会使用笔记本电脑、平板电脑或手机与发言者进行互动,因此该应用应该支持尽可能多的设备。

这个应用需要访问任何设备硬件吗?

该应用不需要访问用户的任何手机硬件,如相机,所以这不是你需要为这个项目担心的事情。

最后的决定是:让我们构建一个 Web 应用

鉴于这款应用不需要访问设备硬件或收取访问应用的费用,鉴于用户群不可预测地使用任何特定设备,并且观众与演示者之间的通信绝对需要互联网连接,因此将我们的应用构建为 web 应用是最有意义的。

摘要

在这一章中,你学习了构建本地应用和 web 应用的优点和缺点。因为没有正确或错误的答案,应用开发人员需要单独考虑每个项目,以确定哪种方法对他们最有利。

在下一章,我们将进入应用的细节,以确保在我们开始编码之前,我们所有的鸭子都在一条线上。

1

2

3

4

5

6

7

8

9

10

11

12https://trigger.io/13http://en.wikipedia.org/wiki/Windows_Runtime

14

15

16

五、确定应用的功能和结构

现在我们知道我们正在构建一个 web 应用,并且我们已经准备好了工具,我们可以开始真正规划应用的功能了..在本章中,您将:

  • 定义应用的功能
  • 确定用户将如何与之交互(以及基于用户角色的不同之处)
  • 为网站的后端代码和数据库制定一个计划
  • 想出一个前端的结构
  • 为应用创建一个简单的线框

在这一章结束的时候,你应该对应用的构建有一个非常清晰的想法;这将消除浪费的开发时间,并有助于保持应用正常运行。

这个应用是做什么的?

在我们计划任何事情之前,你需要一个非常清晰简明的特性列表。这将检查我们的特性列表,并在您开始设计和开发时帮助避免任何范围蔓延。

给应用一个使命宣言

让我们从应用的高层次概述开始,这将指导您做出关于应用功能的所有决策。

用一小段话来说,让我们想想如何描述这个应用:

这是一个简单的 Q &一个网络应用,允许特定事件的参与者向演讲者提问,并确认另一个参与者的问题也与他们相关,所有这些都是实时的。此外,演示者应该有一个问题列表和每个问题的提问人数,她可以在向小组给出答案时从中标记出已回答的问题。

App 不做什么?

与应用能做什么相比,同样重要的是它不能做什么。这将有助于决定某个功能是否应该包含在应用中。

该应用旨在成为现场活动的演示者和参与者的工具,最有可能在与其他参与者相同的物理空间中使用。所以 app 应该而不是:

  • 尝试取代现场讨论。

  • 这意味着应用本身不允许评论或回答;它只是提出问题,而不是回答或澄清问题。允许在应用中讨论可能会分散对实际演示的注意力。

  • 提供有关会话的任何附加信息。

  • 该应用不会告诉与会者会议的内容,也不会提供任何资源。焦点仍然在演示者身上,因为这个应用并不参与演示,只是问答。

  • 为演示者提供任何组织工具。

  • 演示者的仪表板只是一个问题列表,按受欢迎程度排序,可以标记为“已回答”这不是帮助演示者更好地进行演示的工具;它可以帮助他们回答房间里的问题。

用户会扮演什么角色?

既然我们知道了应用做什么——提问和回答问题——我们就可以定义与它交互的不同类型的用户。

因为这是一个非常简单的应用,所以只有两个用户角色:演示者和参与者。

提出者

演示者将在应用的主视图上创建一个房间,然后使用创建房间时生成的链接与与会者共享该房间。将向他提供一个结束会话的按钮,关闭该按钮以回答进一步的问题。

当提问时,它们会实时出现在演示者的屏幕上,并带有一个 UI 元素,允许他将问题标记为已回答。

出席者

与会者将通过单击演示者提供的链接或输入房间 ID 来加入房间,房间 ID 将是 URL 中不是域名的部分(即http://example.com/1234,其中1234是房间 ID)。

进入会议室后,与会者将看到之前在此会议中提出的问题(如果有),并可以选择对该问题进行投票表决。任何新问题都会实时出现在屏幕上。

还将有一个表格,与会者可以通过它提出自己的问题,这些问题将被添加到她已经投票表决的问题列表中。

最后,如果一个问题没有得到满意的回答,或者这个问题不适合整个小组,将会有一个链接直接给演示者发电子邮件。

前端规划

你的下一步是开始充实你将采用的应用前端的方法,或者用户将与之交互的应用的可视部分。

在本节中,您将为所有前端技术组织您的开发方法。

我们在使用什么技术?

我们已经讨论了我们将用于此应用的工具,但概括一下,您将使用以下内容构建网站的前端:

  • HTML5
  • CSS3
  • Java Script 语言
  • 框架
  • Pusher JavaScript 库

您将使用每种技术的多个方面来为应用制作一个好看、易用、简单的前端。

使用 HTML5

和几乎所有的 web 应用一样,前端将使用 HTML 标记构建。对于这个应用,您将利用 HTML5 中引入的一些新元素和属性。

您可能已经见过或使用过任何新元素,比如<article><footer>元素。如果您没有,它们在功能上与<div>元素相同,但是从语义的角度来看它们更好,因为它们是自描述的。

你还将利用新的<input>类型——即email类型——来鼓励触摸屏设备定制键盘,以适应输入的数据。

最后,您将使用新的数据属性将信息传递给级联样式表(CSS)和 jQuery,用于各种目的。我们将在下一章更深入地探讨如何和为什么这样做。

用 HTML 实现极简主义

image 小心以下是作者的边界咆哮。

好的 HTML 标记是一个相对的术语。怎么好?有功能就好吗?格式良好?有效吗?

像编程中的几乎所有东西一样,HTML 标记“好”的大部分原因完全是主观的。没有全球公认的“正确方式”来编写标记,这可能是一件好事,因为它允许开发人员创造性地使用 HTML 元素来创建真正聪明的布局。

然而,有种绝对错误的方式来编写标记。比如对每个应用的 CSS 类使用不同的嵌套<div>错误的;这只是草率的编程。使用完全不透明的类名是错误的。把适当的缩进和嵌套抛到九霄云外是错误的。

对于这个应用,我们将 HTML 视为一个舞池:它需要清除任何碎片和障碍,以便呈现的数据不会被绊倒。

让我们回顾一些最常见和最有害的标记实践,并计划而不是使用它们中的任何一个。

滥用

元素

HTML 标记最明显的滥用是为每个要应用的新样式应用新的<div>元素。这不仅给文档对象模型(DOM)增加了更多的元素,使 jQuery 遍历这样的事情变得更加复杂和低效,而且使以后的维护成为一场噩梦,尤其是当其他人不得不处理您的代码时。

使用完全不透明的类名

另一个令人沮丧的标记失误是使用了神秘的类名和 id。在 99.999%的情况下,通过将类名float-right缩短为fl来节省几个字节的好处完全被令人头痛的事情抵消了,因为这使人们不得不在以后试图理解和维护代码。

完全忽略缩进

缩进在 HTML 中是可选的,因此它在很大程度上被许多开发人员忽略了。这不一定会损害他们的工作质量,但会使处理他们的加价变得比必要的更痛苦。

考虑到至少对标记进行排序所需的额外时间可以忽略不计,特别是考虑到大多数现代集成开发环境(IDEs)默认情况下会正确缩进代码——不花一点时间来确保以后必须处理标记的人可以通过添加一些空白来阅读它,这简直是懒惰。

好的标记与坏的标记的例子

为了展示关注标记所能带来的巨大差异,让我们来看一个非常小的例子,这个例子比较了一个糟糕的标记片段和一个完成相同任务的重写的片段。

错误标记的示例

首先,我们来看看不好的标记。在不到 3 秒钟的时间内,您能找出这是什么,以及如何解决额外的保证金问题吗?

<td><div class="sb"><div class="im csh"><a
href="http://www.example.com/inventory/1234/"
title="Example Product"><img width="150" height="150"
src="http://www.example.com/img/1234.jpg" class="at"
alt="Example Product" title="Example Product" /></a></div></div></td>

这段代码完全有效,但是太糟糕了。这些<div>都是从哪里来的?sb班到底是干什么的?那么cshim或者at呢?要解决这个假设的边距问题,您必须检查这个代码片段中引用的每个类,然后才能确定有问题的样式。

最重要的是,由于完全没有格式化,如果不花额外的时间真正查看,很难说出这个片段是什么。这将会减慢将来的维护,尤其是如果有其他人在做的话。

同样的例子,但是有更好的标记

将前面的标记与下面的代码片段进行对比:

<td class="show-badge">
    <a href="http://www.example.com/inventory/1234"
       class="image cushioned"
       title="Example Product">
        <img src="http://www.example.com/img/1234.jpg"
             class="product-thumbnail"
             alt="Example Product" />
    </a>
</td>

你花了多长时间才发现这是一个产品的图片缩略图?不长,尤其是与原始片段相比。

多余的<div>已经被删除,因为所有这些样式都可以应用于<td><a>元素。类名已经扩展到人类可读的程度。(据猜测,我们假设的保证金问题可能来自于cushioned类。)元素已被格式化,以便于扫描。

对你的标记做这些小小的调整将会多花几秒钟,但是会节省小时的沮丧。所以你应该去做。

CSS3,媒体查询 ,以及它如何影响设计和 HTML

为了设计应用,你将使用 CSS,包括 CSS3 的一些新特性。使用像box-shadowborder-radius这样的功能将允许你给你的应用一层设计润色,而不必处理大量的图像。

您将实现的 CSS3 最令人兴奋的新特性可能是使用媒体查询的自适应布局。这将允许您基于浏览器的宽度应用样式,消除浏览器嗅探、应用的多个版本或仅在特定设备子集上看起来不错的布局的需要。

然而,使用媒体查询带来了一些问题,应该在应用的设计和标记阶段解决。因为布局会改变以最适合它所显示的屏幕,所以从一开始就需要考虑布局的一定程度的灵活性。

一列和两列布局

当查看实现自适应布局的网站时,最常用的技术之一是在较小的屏幕上查看网站时,从多列变为单列。

乍一看,这似乎没什么大不了的,但是如果内容需要以除了第一列、第二列等等之外的任何方式流动(反之亦然),这可能会给布局带来麻烦。(例如,如果广告突然被推离所有最新内容下方的屏幕,侧边栏中有广告的网站可能会与广告商陷入困境。)

练习 5-1:使用响应式布局

为了展示 responsive layout 可能带来的内容回流挑战,我们来看一个示例站点。

在这个练习中,你负责将图 5-1 中的设计转换成一个响应式布局。

9781430246206_Fig05-01.jpg

图 5-1。需要转换为响应布局的示例网站

这是一个非常标准的布局:一个页眉、一个页脚、一个包含博客条目的左侧栏和一个包含标准“侧边栏”内容(广告和时事通讯截图)的右侧栏。

在小屏幕上重排内容

从逻辑上讲,当站点在手持设备上呈现时,并排显示两列是没有意义的;内容会过于混乱和狭窄。自然的解决方案是切换到单列布局,这允许内容以更美观的方式显示。

然而,这个网站的广告商不会容忍广告被埋在博客条目下面——随着更多内容的积累,可能会有多达八个条目显示在左栏中——所以简单地将左栏堆叠在右栏之上不会有所减少。

要解决这个问题,您需要以某种方式将左栏的内容与右栏的内容交错。这需要对标记进行一些调整,以允许主要内容区域的每个部分独立流动。

通常,像这样的设计可能会被标记为如下形式(简化):

<header>...</header>

<section id="main-content">
    <article>...</article>
    <article>...</article>
</section>

<aside id="sidebar-content">
    <div id="ads">...</div>
    <div id="newsletter">...</div>
</aside>

<footer>...</footer>

然而,这会将列放入不允许您重排内容的“框”中。要解决这个问题,您需要让每一部分内容都有自己的盒子:

<header>...</header>

<article>...</article>
<aside id="ads">...</aside>
<article>...</article>
<aside id="newsletter">...</aside>

<footer>...</footer>

这种标记类似于单列布局的外观:以一个条目开头,然后显示广告,然后显示另一个条目,最后显示新闻稿注册。将来,更多的条目将出现在时事通讯的下方。

现在,当屏幕足够宽时,您可以使用媒体查询将内容排列成两列,但当屏幕不够宽时,可以按可接受的顺序堆叠内容。

通过媒体查询进行内容重排

要让内容正确显示,请从最小的屏幕尺寸开始。标记没什么特别的;只是一些基本的风格:

article {
    position: relative;
    margin: 0 0 2em;
    overflow: hidden;
}

aside {
    margin: 1.5em 0;
    padding: 1.5em 0;
    border-top: 1px dashed #955240;
    border-bottom: 1px dashed #955240;
}

然后,当屏幕足够宽时,应用附加样式将内容分成两列。对于本例,应用这些规则的媒体查询被设置为匹配任何小于 768 像素的屏幕,这是一种常见的平板电脑分辨率。

这种风格并不疯狂:博客条目向左浮动,侧边栏内容向右浮动,将内容分为两栏:

@media screen and (min-width: 768px) {

    article {
        position: relative;
        float: left;
        width: 55%;
        margin: 0 3% 2em 0;
    }

    aside {
        float: right;
        width: 42%;
        margin: 0 0 2%;
        padding: 0;
        border: none;
    }

}

image 注意百分比的使用允许列根据屏幕大小增长或收缩。在桌面浏览器中查看此布局时,您可以调整窗口大小来查看内容重排,以利用可用空间。

一旦布局完成,窄屏幕将看到一个单列布局,广告可接受地位于第一个条目的下方(图 5-2 )。

9781430246206_Fig05-02.jpg

图 5-2。从左到右显示回流内容的三个屏幕

image 要查看实际布局并查看上述练习的完整源代码,请访问https://github.com/jlengstorf/responsive-design

可点击区域和胖手指

设计将在移动设备上使用的应用的另一个不同之处是当点击(或点击)时精确度的损失。如果你曾经试图在手机上使用一个专为全尺寸浏览器设计的网站,你可能会经历这样的挫折:试图点击列表中显示的链接,但由于手指太大而无法准确选择,所以意外地选择了错误的链接。

由于这个原因,你的用户界面应该有大按钮,并确保链接周围有足够的空间,以确保用户即使用胖手指也能轻松点击它们。

效果和动画

为了让应用感觉像是在响应用户动作,您还需要实现效果和动画,包括指示用户选择了哪个输入或控件(包括键盘控制)的代码,以及表示请求的动作已经执行的简单动画。

投票表决一个问题

当与会者点击某个问题的向上投票按钮时,您会希望添加一种效果来提供视觉反馈。在这种情况下,只需突出显示控件并增加投票计数就足够了。

回答问题

当演讲者回答问题时,它应该停留在屏幕上,但移到不太突出的地方。您需要实现一些效果来对问题列表进行重新排序,将已回答的问题移到未回答的问题下面,以及降低其不透明度来保持对未回答问题的关注。

来自 UI 元素的反馈

用户可以在应用中的各种 UI 元素上悬停或切换,所以你要确保添加效果,让他们知道什么是可点击的,或者哪个表单元素有焦点。这可以用 CSS 来完成。

其他影响

每当其他用户执行某些操作时,网站的后端将通过 Pusher 用新数据更新应用,因此必须有适当的效果来处理 DOM 操作以显示更新。我们将在后面的章节中更深入地讨论这些影响。

后端规划

网站的后端是所有用户操作需要被处理和存储的地方。为了使应用易于维护和快速开发,让我们来看看脚本和数据库应该如何组织。

模型视图控制器

web 应用软件设计的行业标准是模型-视图-控制器 (MVC) 模式。有几十种可用的 PHP 框架,其中大多数都是基于 MVC 模式的。

因为它的广泛使用以及它被普遍认为是 web 应用的最佳方法这一事实,您将使用 MVC 模式来构建这个应用。

MVC 编程模式简介

MVC 听起来比实际更复杂。MVC 的核心概念是将任何表示元素(视图,通常是 HTML 标记)与数据(模型,通常是存储在数据库中的信息)和逻辑(控制器,可能是 PHP 代码)分离开来。

使用 MVC 模式的应用有三个非常明显的区别:

  • 控制器处理数据和解释用户输入的类和代码。这就是解释用户指令(比如一个页面请求),向模型请求所需的数据,操纵它,并将其发送到视图进行输出。
  • 建模读写数据的类。就这样。这些类不对数据进行任何操作,也不生成任何显示给最终用户的输出。
  • 这是用来向用户显示信息的代码。这里没有任何形式的逻辑;在 web 应用环境中,视图应该尽可能接近普通的 HTML。

对基于 MVC 的应用的典型请求应该与这些非常相似(见图 5-3 )。

9781430246206_Fig05-03.jpg

图 5-3。显示典型 MVC 请求的操作顺序的图表

让我们带着我们正在构建的应用,走一遍图 5-3 所示的步骤。

  1. 用户通过统一资源指示器(URI) 传递命令,与客户端应用(控制器)进行交互。这可能是一个演示与会者填写并提交问题表,或投票表决一个问题。或者可能是演示者将问题标记为已回答。这导致客户端应用向服务器发出请求。
  2. 控制器处理该请求,基于请求参数确定动作。然后,它根据这些输入与适当的模型进行交互。
  3. MVC 中的模型也可以表示或包含数据访问层。在我们的应用中,就是这种情况。因此,当用户提交一个问题与模型进行交互时,它会将问题存储在数据库中。如果是问题投票表决的结果,问题已经被投票表决的事实也将被保留。在模型处理了交互之后,它将适当的数据返回给控制器。
  4. 控制器处理来自模型的响应,并将其传递给视图。
  5. 视图根据控制器的响应进行更新。正在提交的问题可能会导致视图发生变化,向演示者显示所有问题,同时突出显示新添加的问题。或者在向上投票的情况下,问题可以改变视图中的位置。

用一个愚蠢的类比来解释 MVC

为了以最简单的形式理解 MVC,考虑订购一份比萨饼。你打电话给披萨店,和收银员说,收银员会帮你点菜。当你点完菜后,收银员告诉厨师点了什么,厨师就开始做披萨。披萨出炉后,厨师将披萨交给收银员,收银员将披萨打包后交给送货司机,随后送货司机会将披萨送到你家。

在这个例子中,收银员是控制者。你通过电话与她互动——URI(??)——给她你的命令——?? 的指示(??)——她会翻译。

厨师是。收银员向他要你要的披萨——数据——他给了收银员。厨师接受请求,制作比萨饼,在烤箱中烹制,然后交给收银员。

然后,收银员准备好要交付的比萨饼— 逻辑—并把它交给司机,司机是从交互中产生的视图。收银员给她指示后,司机把披萨送到你面前——展示——供你享用。司机除了给你送披萨,从来不会以任何方式碰披萨;如果你的披萨外卖司机染指了你食物的原材料,那就太不可思议了。

image MVC 可以追溯到 1979 年 2 从它的概念提出到现在的 30 多年里,已经被数十种语言的程序员解释、再解释、再解释。实现 MVC 没有绝对正确的方法。这是一个指导发展的哲学原则,而且,和所有的哲学一样,它在不同的阵营中会有不同的实践。不要为此感到紧张。

确定数据库结构

为了存储关于房间和问题的信息,你需要一个数据库。在本节中,您将列出需要存储的数据列表,并确定这些数据的结构。

需要存储哪些数据?

首先,让我们列出应用正常运行需要存储的所有内容。这包括房间和问题信息:

  • 房间的名称
  • 房间的唯一标识符
  • 房间是否处于活动状态
  • 演示者的姓名
  • 演示者的唯一标识符
  • 演示者的电子邮件
  • 这个问题
  • 问题的唯一标识符
  • 不管这个问题是否被回答
  • 这个问题的投票数

数据库规范化

为了保持数据库整洁并消除重复信息,需要对数据库进行规范化。 3

为什么数据库规范化很重要

说明数据库规范化为什么重要的最简单的方法是看一个简单的例子。

想象一下,一所大学有一个数据库,需要跟踪它的教授以及每个教授目前在教什么课。它需要存储教授的姓名、电子邮件地址、聘用日期和他们所教班级的 ID。

如果数据库是而不是规范化的,它可能看起来像表 5-1 。

表 5-1 。尚未规范化的表

Table

乍一看,这可能没有错,但是当你开始处理数据时,事情就开始变得有点不可靠了。

例如,当一个新教授被雇用,但还没有上课,会发生什么?该条目将有一个空列,这可能会导致读取该数据的任何应用出现问题。

反过来,一个老师教两个班或者两个班以上怎么办?除了一列之外,所有列都有重复数据(见表 5-2 )。

表 5-2 。一个教授教两个班的非规格化表

Table

还有删除信息的问题:如果汤姆·琼斯停止教 MATH-315,那一行从数据库中删除,他的其他信息也会丢失,这可能不是想要的结果。

规范化数据库

为了使这个数据库更易于管理,应该将其规范化,这将把数据分成两个表:一个用于教授数据(见表 5-3 ),另一个用于将教授映射到他们所教的班级(见表 5-4 )。

表 5-3 。没有班级信息的教授表

名字 电子邮件 雇佣日期
简·多伊 jane.doe@example.org 2004-08-09
汤姆·琼斯 tom.jones@example.org 2007-01-15

表 5-4 。新表格将教授与他们所教的班级对应起来

教授 班级
简·多伊 ECON-101
简·多伊 ECON-201
汤姆·琼斯 数学-315

通过将数据拆分到两个表中,现在可以在不丢失个人数据的情况下取消汤姆·琼斯作为数学 315 教师的职务,并且也可以让无名氏教两个班而不复制她的所有其他数据。

然而,这个表仍然不理想,因为它在更新教授的姓名时存在困难。例如,如果雇佣了另一个叫 Jane Doe 的老师,如何在教授和所教班级的映射表中唯一地识别出哪个老师是被引用的?可以进一步规范化为教授使用数字 ID 而不是他们的名字,这解决了这个问题(见表 5-5 和表 5-6 )。

表 5-5 。添加了 ID 字段的 Professors 表

Table

表 5-6 。班级信息表现在使用教授的 id 而不是他们的名字

教授 _ID 班级
one ECON-101
one ECON-201
Two 数学-315

在这一点上,这个数据库是充分规范化的,在将来添加、修改或删除数据时不会出现任何问题。

确定表格和结构

考虑到标准化,让我们为您的应用定义表。概括地说,您需要存储的数据如下:

  • 房间的名称
  • 房间的唯一标识符
  • 房间是否处于活动状态
  • 演示者的姓名
  • 演示者的唯一标识符
  • 演示者的电子邮件
  • 这个问题
  • 问题的唯一标识符
  • 不管这个问题是否被回答
  • 这个问题的投票数

如果您正常地对这些数据进行分组,您应该得到四个表:

  • 提出者
  • 空间
  • 问题
  • 投票

演示者表格

第一个表presenters,存储演示者的姓名、电子邮件和唯一的 ID(见表 5-7 )。

表 5-7 。presenters

编号 名字 电子邮件

房间表

rooms表将存储房间的唯一 ID、名称以及是否处于活动状态(参见表 5-8 )。

表 5-8 。rooms

编号 名字 不活动的

问题表

questions表将存储问题的唯一 ID、问题所属的房间、问题文本以及问题是否已被回答(参见表 5-9 )。

表 5-9 。问题表

编号 室友 问题 已回答

问题投票表

question _ votes表将存储问题的 ID 和当前的票数(见表 5-10 )。

表 5-10 。question_votes

问题 id 投票计数

房间所有者表

Room_owners表将存储房间和主持人的 id(参见表 5-11 )。

表 5-11 。room_owners

室友 演示者 id

将所有东西放在线框图中

此时,我们已经完成了大部分计划;剩下的就是快速绘制应用的线框,以获得布局的大致想法。

组织主页

主页需要服务于两个目的,以满足我们两个用户角色的需求。

首先,它需要为演示者提供创建新房间的方法。这将需要一个要求提供必要信息的表格。

其次,如果与会者没有与会话的直接链接,他们需要能够加入房间。这还需要一个接受房间 ID 的表单。

除了这两个表单之外,还应该有一个用于标识应用的页眉和一个用于提供附加信息(如版权)的页脚。

更宽屏幕的线框

在更宽的屏幕上,并排设置这两种形式是有意义的,因为它们并不比另一个更重要或更不重要。当我们把这些都放到一个基本的线框中,它看起来像图 5-4 。

9781430246206_Fig05-04.jpg

图 5-4。更宽屏幕的主页线框

较窄屏幕的线框

在较窄的屏幕上,两栏布局是不可行的,所以我们必须将内容重排为一栏。与会者加入会议室的表格应该放在顶部,原因有二:它将比演示者表格短得多,而且有理由认为使用该应用参加会议的人将多于演示者。

内容重排后,看起来会像图 5-5 。

9781430246206_Fig05-05.jpg

图 5-5。窄屏幕的主页线框

为与会者组织问答页面

与会者加入房间后,应用需要显示房间的名称和演示者是谁,并向她显示提问表格和现有问题列表。

页眉和页脚将在整个应用中保持一致,因此只有主要内容区域需要改变。

更宽屏幕的线框

同样,在更宽的屏幕上,内容可以分成两列,但在这种情况下,其中一列应该比另一列宽,因为侧边栏中不会显示太多内容。

提出新问题的表格应该放在主栏的顶部,下面是问题。

在右侧,应该显示房间的名称和演示者的信息。

插入这些信息后,线框看起来像图 5-6 。

9781430246206_Fig05-06.jpg

图 5-6。为与会者设计的问答页面被框在更宽的屏幕上

较窄屏幕的线框

对于单列布局,我们实际上要把包含房间信息的侧栏放在顶部。这样做的原因是内容很重要——没有它,与会者就不知道他们参加了哪个会议——而且非常短,所以它不会把其余的内容压得太低。

重排内容后,你会看到类似于图 5-7 的东西。

9781430246206_Fig05-07.jpg

图 5-7。为与会者设计的问答页面适合更窄的屏幕

为演示者组织问答页面

面向与会者的问答页面和面向演示者的问答页面之间只有一些小的区别。也就是说,删除了“提问”表单,并为演示者添加了一些控件。

更宽屏幕的线框

将“提问”表单换成演示者控件后,两栏视图中的布局没有太大变化。然而,控件并没有放在问题列表的顶部,而是放在房间信息的下面,以便将注意力更多地放在回答问题上(参见图 5-8 )。

9781430246206_Fig05-08.jpg

图 5-8。Q&为演示者设计的页面被框在更宽的屏幕上

较窄屏幕的线框

与与会者的布局类似,房间信息和控件将移动到单列视图中问题列表的顶部(参见图 5-9 )。

9781430246206_Fig05-09.jpg

图 5-9。Q&专为窄屏幕的演示者设计的页面

摘要

此时,您已经熟悉了所有需要的技术,您已经为站点的前端和后端开发制定了一个攻击计划,并且您已经有了一个线框来通知您的设计。

在下一章中,你将根据本章的计划从头开始设计和构建应用。

1 关于适应性布局的图库,参见http://mediaqueri.es

2

3

六、设计应用

因为这是一本关于开发而不是设计的书,所以这一章会很短。它讨论了一些 web 应用特有的设计注意事项。此外,我们将谈一谈如何确保在 Photoshop 中制作的设计能够很好地翻译到 web 上。

image 注意本章假设你已经接触过 Adobe Photoshop,对它的界面相当熟悉,而且——也许是最重要的——你关心设计。您将能够在另一个程序(如 GIMP)中创建相同的作品,但以下步骤不会正确匹配。

image 警告如果你是一名铁杆开发者,正考虑跳过这一章,那么除了带你浏览应用的设计,它还提供了一些调整 Photoshop 设置的技巧,以确保编码后的设计看起来像 Photoshop 中的布局。如果您在团队中工作并做任何前端工作,当您的创意团队想知道为什么字体看起来略有不同或其他微小的布局不一致时,这可能会省去您一些头痛。

设定设计目标

对于任何设计项目来说,从明确的目标开始是很重要的。这有助于防止设计偏离轨道或与应用的意图相冲突。

因为这种设计是针对需要在标准 web 浏览器和手持设备上工作的应用,所以这种设计的目标如下:

  • 在绝对必要的情况下需要尽可能少的图像
  • 保持设计简洁,只关注所需的内容
  • 使用大型用户界面(UI)元素,使应用易于在触摸屏上使用

如果设计坚持这三个目标,那么从桌面到移动的过渡将会很容易,使用简单,导航也非常容易。

定义调色板

接下来,你需要为应用选择一个调色板。这完全是一个主观的决定,但是所选择的颜色应该符合一定的准则:

  • 背景颜色和文本颜色应该有很高的对比度,以确保易于阅读
  • 从易读性的角度来看,较亮的背景对眼睛来说更容易一些
  • 包含强调色来吸引对重要元素(如链接和按钮)的注意通常是个好主意

对于这个应用,让我们使用简单的地球色调加上明亮的橙色强调色(见图 6-1 )。保持中性色可以让眼睛看起来更舒服,防止颜色冲突,这会让网站上的文字更难阅读。本书中不会正确显示强调色,因为它是灰度的。要正确查看强调色,请将 Photoshop 中的拾色器设置为图中所示的十六进制值。

9781430246206_Fig06-01.jpg

图 6-1。为应用选择的调色板浅色将用于背景,深色用于文本

选择字体

最近对@font-face的广泛支持为设计师们打开了一个全新的可能性世界。基于网络的应用不再局限于一小部分网络安全字体;相反,交互设计师现在只受到他们想象力的限制(还有 OFL 许可 1 )。

利用你新获得的自由,你现在需要为应用选择字体。像调色板一样,这完全是一个主观的决定,但是有一些通用的指导原则可以帮助选择稳定的字体:

  • 标题可以使用比网站上其他内容更有趣的字体。
  • 正文应该使用简单易读的字体,以确保阅读起来不难。
  • 设计一致性很重要。不要过分使用不同的字体;坚持一个或两个一起工作很好的。
  • 如果选择的字体非常独特,文档中使用的其他字体应该更加端庄,以防止冲突。
  • 额外提示:有疑问时,使用 Helvetica。 2

为了创造视觉趣味,我们应用中的标题将采用古铜色黑色, 3 ,这是一种非常独特的字体,在广告的广告狂人时代大量使用。它经典而大胆,看起来不傻,这使得它非常适合大字体。

由于库珀·布莱克的圆形衬线,紧凑的字距(字符之间的间距)增加了视觉趣味。通过将字母挤在一起,我们获得了简洁的视觉效果。

相比之下,该网站的其余文字将设置为新闻哥特式 4 ,就字体而言,这明显不那么直言不讳。它的线条简洁有力,非常易读——即使是小字体——但不会像库珀·布莱克那样引人注目。

为了提高可读性,字距应该向外调整一点,让字符“呼吸”它还与标题字体形成了很好的对比。

这两种字体很好地相互补充,将形成一个有凝聚力的演示文稿,而不会与互联网上其他使用传统 web 安全字体(如 Arial 和 Georgia)的网站融为一体。

当我们将它们放在一个样本中时,最终的字体选择看起来非常好(见图 6-2 )。

9781430246206_Fig06-02.jpg

图 6-2。所选字体的样本

字体渲染:PHOTOSHOP vs . @ FONT-FACE

没有网络字体经验的设计师经常抱怨说,他们看起来和在 Photoshop 中不一样了。发生这种情况是因为 Photoshop 使用的抗锯齿与大多数 web 浏览器不同,这意味着字体的渲染会稍有不同。大多数人不会注意到这种差异,但这种差异足以让一个刚刚花了一周时间把自己的设计做得恰到好处的创意型人才感到心痛(见图 6-3 )。

9781430246206_Fig06-03.jpg

图 6-3。在浏览器(上图)和 Photoshop(下图)中渲染的新闻哥特风格

幸运的是,Photoshop 提供了更改布局中字体抗锯齿的选项。其中一个选项——强——与大多数浏览器使用的抗锯齿非常相似,它消除了 Photoshop 布局与其在 Web 上的外观之间的明显差异(参见图 6-4 )。

9781430246206_Fig06-04.jpg

图 6-4。使用强抗锯齿类型,字体匹配更紧密

要更改 Photoshop 中的抗锯齿模式,请选择文本图层(或图层;这可以批量更改而不影响其他设置)并打开“字符”面板。在右下角,有一个下拉菜单,可能默认选择了平滑。改成 Strong 就万事俱备了(见图 6-5 )。

9781430246206_Fig06-05.jpg

图 6-5。在 Photoshop 中更改文字的抗锯齿

设计常见的站点元素

准备好你的颜色和字体,你就可以开始设计了。在 Photoshop 中,创建一个宽1024px840px的新文档。开始设置背景为浅色,#FBF7E7

创建标题

设计的第一个元素是标题,它将与设计的主体分开,以深色#1F1B0C作为背景色。

  • 1.对于页眉,选择矩形工具,画一个宽1024px240px的矩形。将其与文档的顶部和左侧对齐。

  • 2.接下来,选择水平文字工具,并在页面上绘制一个包含主要标题的文本区域。打开字符面板,进行如下设置:

  • 字体:古柏黑

  • 尺寸:110 磅

  • 跟踪:-80

  • 颜色:#fbf 7 和 7

  • 抗锯齿:强

  • 3.添加应用的标题,实时 Q & A ,并将其置于标题中央。

  • 4.要添加副标题,请使用水平文字工具在标题下绘制另一个文本区域。在“字符”面板中,将设置更改为以下内容:

  • 字体:新闻哥特式光

  • 尺寸:18 磅

  • 跟踪:100

  • 颜色:#fbf 7 和 7

  • 抗锯齿:强

  • 5.添加副标题,课堂、演示和会议的实时反馈系统

保存到目前为止的工作;你现在已经得到了应用的标题(见图 6-6 )。

9781430246206_Fig06-06.jpg

图 6-6。设计割台的工作进展

创建页脚

接下来,让我们添加网站页脚。再画一个1024px宽、50px高的矩形(就像你为标题画的一样),并将其与底部和左侧对齐;这将是页脚的背景。

  • 1.使用水平文字工具,以页脚背景为中心绘制一个文本区域;然后将“字符”面板中的设置更改为以下内容:

  • 字体:新闻哥特式光

  • 尺寸:13 磅

  • 跟踪:25

  • 颜色:#fbf 7 和 7

  • 抗锯齿:强

  • 2.添加版权信息, 2013 杰森·伦斯托夫&菲尔·莱格特;然后按下Tab并选择右对齐选项。制表符允许版权信息左对齐,而制表符后的文本右对齐。现在添加其余的页脚文本:的一部分实时 Web 应用:用 HTML5 WebSocket,PHP,和 jQuery 。获取图书|源代码(在 GitHub 上)
    *** 3.对于链接 Get the Book and Source Code(在 GitHub 上),依次选择每一个并将字体设置为 News Gothic Medium,打开下划线,将颜色设置为你的强调色,#E06F00。**

**再次保存;页脚准备就绪(参见图 6-7 )。

9781430246206_Fig06-07.jpg

图 6-7。应用的页脚

表单元素

现在您已经有了应用的基本包装,您可以开始整合用户界面的外观和感觉了。因为这个应用完全是关于交互的,它严重依赖于表单在与会者和演示者之间发送信息。

因为这个应用将依赖于它的表单元素来获得大部分美感,所以让我们从设计输入和按钮样式开始,我们可以在应用的所有视图上使用它们。

文本和电子邮件输入

文本输入将用于这个应用上几乎所有的交互,所以它们是一个逻辑起点。我倾向于将所有元素保存在 Photoshop 的不同图层上,然后将它们分组到文件夹中。随意使用任何适合你的组织方法。

  • 1.  Playing off the roundness of our headline font, grab the rounded rectangle tool and set the border radius to 6px. Draw a rectangle 430px wide by 40px tall and make it white (#FFFFFF). This will serve as the base for the input (see Figure 6-8).

    9781430246206_Fig06-08.jpg

    图 6-8。带 6px 圆角的白色矩形

  • 2.  Next, we need to make it look a little more like an input. To start, let’s give it a border. Bring up the Layer Style panel by clicking the Layer Style (fx) button at the bottom of your layers panel while the rectangle layer is active (see Figure 6-9).

    9781430246206_Fig06-09.jpg

    图 6-9。Photoshop 中的图层样式按钮

  • 3.  After the Layer Style button is clicked, several options will be listed. Click Stroke and the Effects dialog will open.

    image 提示隐藏目标路径——当你选择图层时 Photoshop 显示的矩形轮廓(见图 6-9 的例子)——以便更好地了解你编辑效果时的样子。要隐藏,按下Command + Shift + H或点击查看image显示并取消选中目标路径。

  • 4.  In the Effects dialog, give the input a 2px stroke and place it outside the shape. Use the dark color for this. The rest of the settings should remain at their default values (see Figure 6-10).

    9781430246206_Fig06-10.jpg

    图 6-10。添加了笔画的输入,加上所有的设置

  • 5.  Next, add an inner shadow to the input, which will give it some dimension. Click the Inner Shadow check box below Stroke in the Layer Style panel, which brings up new settings (if it doesn’t, click the actual label next to the check box). Set the shadow color to dark (#1F1B0C), the angle to 135º, distance to 2px, and the size to 14px. Change the blending mode to Normal as well, and drop the opacity to 30%, which gives you an input-looking rectangle (see Figure 6-11).

    9781430246206_Fig06-11.jpg

    图 6-11。对输入矩形应用阴影的设置

    image 提示将混合模式设置为正常可以确保阴影很好地转换为 HTML/CSS。虽然混合模式正在设计中 5 (希望很快),但 CSS3 目前还不支持,因此应该暂时避免在网页布局中使用。

    image 提示使用 135 的阴影,这样在 CSS 中 X 和 Y 的偏移量是相同的。这通常看起来很好,并防止 Photoshop 中的阴影和 Web 上的阴影不一致。

  • 6.最后,输入需要标签的样式,这将让用户知道她应该在字段中输入什么。这将是输入右上角的一个小文本字段,设置如下:

  • 字体:新闻哥特式粗体

  • 跟踪:25

  • 颜色:#1F1B0C

  • 抗锯齿:强

  • 7.  Use the text Tell us your name (so attendees know who you are). Align it with the input, and your input is complete (see Figure 6-12).

    9781430246206_Fig06-12.jpg

    图 6-12。输入的标签完成了样式化

提交按钮

如果表单不能被提交,那么它们对我们没有太大的帮助,所以我们需要一个提交按钮来配合输入。

  • 1.就像文字输入一样,使用圆角矩形工具,边框半径设置为6px。创建一个宽310px54px的盒子,设置为深色(#1F1B0C)。这将是你的按钮的基础。

  • 2.接下来,使用具有以下设置的文本工具:

  • 字体:古柏黑

  • 尺寸:30 磅

  • 跟踪:-50

  • 颜色:#fbf 7 和 7

  • 抗锯齿:强

  • 3.使用文本创建您的房间并将文本置于提交按钮的中央。

  • 4.  To give it a little dimension, open the Layer Style panel and add a drop shadow using the dark color (#1F1B0C) at 30% opacity, angled at 135º, and set at a size of 10px (see Figure 6-13).

    9781430246206_Fig06-13.jpg

    图 6-13。带有细微阴影的提交按钮

设计主视图

现在,网站的基本元素已经设计好了,您可以开始将它们组装到主页设计中。

此页面为用户提供了两个选项:他们可以作为演示者创建文件室或加入现有文件室。为了简单起见,除了页眉和页脚,这两个选项应该是页面上仅有的内容。

创建房间表单

首先放置创建创建房间表单所需的输入,它允许即将成为演示者的人为 Q & A:

  • 名字
  • 电子邮件
  • 会话名称

因为这一页上有两个表单,所以只使用左半部分的可用空间。用你的标签描述得更详细一点,向用户解释为什么她需要提供这些信息。不要只是列出他们接受的数据,而是使用以下听起来更人性化的标签:

  • 告诉我们您的姓名(以便与会者知道您是谁)。
  • 告诉我们您的电子邮件地址(以便与会者可以与您联系)。
  • 你的疗程叫什么?

在这些输入的下面,添加一个带有文本 Create Your Room 的 submit 按钮(在上一节中您已经准备好了)。这就完成了想要创建新房间的演示者的表格(参见图 6-14 )。

9781430246206_Fig06-14.jpg

图 6-14。主页视图上演示者的表单

但是,这个表单仍然缺少一些东西。让我们添加一个标题和一段描述性文字,以便清楚地说明这个表格的用途。首先在表单上方添加一个标题,设置如下:

  • 字体:古柏黑
  • 尺寸:48 磅
  • 跟踪:-50
  • 颜色:#1F1B0C
  • 抗锯齿:强

简单的用文字呈现?对于标题,它向用户提出了一个清晰的问题,应该很快引导他到正确的形式。通过添加和提交按钮相同的投影来增加一点尺寸:颜色#1F1B0C,透明度 30%,135,大小 10px。

image 提示在图层面板中要复制样式的图层上右键单击(或按住 Control 键并单击),可以快速轻松地将图层样式复制到其他元素;然后从上下文菜单中选择复制图层样式。复制完成后,右击你想要应用样式的图层;然后从上下文菜单中选择粘贴图层样式,样式将被应用。当挑剔的客户希望看到设计中每个元素的多种变化时,这将为您节省时间。

接下来,在标题下添加一段文字,内容为:创建一个房间开始问答环节。使用以下设置:

  • 字体:新闻哥特式罗马
  • 尺寸:24 磅
  • 跟踪:25
  • 颜色:#1F1B0C

将标题居中并复制到表格上方,现在你应该有一个创建新房间的完成表格(见图 6-15 )。

9781430246206_Fig06-15.jpg

图 6-15。完成家庭视图的创建房间表单

加入房间表单

为了保持设计的一致性,加入一个房间的形式将在风格上与创建一个房间的形式相同;只有输入和复制会改变。

为了最大限度地降低与会者的准入门槛,加入房间所需的唯一信息是房间号。

  1. 通过将 create-a-room 表单复制到页面的右半部分,然后删除三个输入中的两个,快速启动该表单。
  2. 把标题改成参加?上面的内容是“使用 ID 加入房间”。
  3. 输入的标签应该变成,房间的 ID 是什么?
  4. 最后,提交按钮的文本应该是“加入这个房间”。
  5. 完成这些修改后,主页视图应该包含两个表单(见图 6-16 )。

9781430246206_Fig06-16.jpg

图 6-16。已完成的首页视图

image 提示现在你已经完成了主视图,将其保存为图层构图,在此状态下创建文档的快照(参见图 6-17 )。这允许您隐藏所有主视图层,并开始在问题视图上工作,而不会丢失任何布局。如果你曾经不得不保存一个 PSD 的多个版本,或者因为覆盖了部分布局而丢失了半天的工作,layer comps 将会是你新的最好的朋友。

9781430246206_Fig06-17.jpg

图 6-17。图层复合面板允许在设计的不同状态之间快速切换,消除了对多个文件或大量重复图层的需求

设计房间视图

房间视图借用了主视图中的一些元素,这将稍微简化这个过程,但它也有多个状态:一个版本供与会者使用,一个版本供演示者使用,一个“关闭的”房间外观供演示者结束其会话后使用。

设计与会者视图

与会者视图有三个不同的部分:

  • 提问的形式
  • 房间信息(他们在哪里,谁负责)
  • 这些问题

为了组织这些信息,您将使用两栏布局,其中左栏更大,并突出重要信息(其中 important 表示最直接有用;在这种情况下,提问表单和问题本身),右栏将包含不太重要的信息—会议和发言人的名称,与会者可能在加入之前就知道—并且会更小。

提问式表单将位于主栏的顶部,不会偏离标准的表单元素设计。

房间信息将遵循库珀黑色标题和新闻哥特式正文的标准;唯一不同的是,它会更小,标题使用 30 磅文本,正文使用 18 磅文本。

这些问题不同于你目前设计的任何问题,所以它们需要更多的思考。需要显示的信息如下:

  • 问题本身
  • 该问题获得的投票数
  • 允许与会者为该问题投票的按钮

问题将使用 News Gothic 以 24 磅显示,这对于在任何屏幕尺寸上阅读都是一个很好的尺寸,即使设备稍微远一点,例如放在桌子上的电话。文本将位于问题布局的右侧。

计票将在库珀黑色 24 点,将坐在最左边的问题。

在计数和问题之间有一个大按钮,用来对问题进行投票表决。用椭圆工具画一个直径为 60px 的圆,颜色为深色,# 1F1B0C 然后使用自定义形状工具在其中心绘制一个浅色箭头#FBF7E7。给黑色圆圈添加和提交按钮一样的阴影,现在你就有了一个投票按钮。

在第一个问题下面添加第二个问题,以确保设计可以处理多个问题。问题之间的微妙分割线——一个 2px 乘 500px 的深色矩形,填充不透明度为 10%,带有来自提交按钮的阴影——完成了与会者页面视图(参见图 6-18 )。

9781430246206_Fig06-18.jpg

图 6-18。与会者的房间视图

设计封闭房间视图

封闭房间视图与标准与会者视图非常相似,但有两个明显的例外:

  • 问问题的形式已经被一个关于房间被关闭的通知所取代。
  • 问题已经被淡化为 60%的不透明度(投票按钮被淡化为 15%的不透明度),以使这个房间在视觉上明显是关闭的。

除了这两个变化,封闭的房间视图几乎没有变化(见图 6-19 )。

9781430246206_Fig06-19.jpg

图 6-19。封闭房间视图

设计演示者视图

最后,该应用需要一个演示者视图。这与与会者视图非常相似,只是有一些例外:

  • 提问形式被删除了,因为演示者不需要问自己问题。
  • 房间的链接会显示出来,因此演示者可以轻松地与她选择的任何人共享房间。
  • 有一个关闭房间的按钮。
  • 投票按钮被移除,取而代之的是一个标记问题已回答的按钮,尽管是在问题的右边而不是左边。

房间链接和关闭按钮都使用了我们已经设计的标准表单元素,但是回答按钮需要一些新的设计:

  1. 使用圆角矩形工具,用深色#1F1B0C 画一个 72px 的正方形。接下来,按住 command 键单击图层面板中的形状,这将在形状周围绘制一个选取框选区。

  2. 选择矩形选框工具并按住 Alt 然后单击并拖动选框的左侧,从上到下重叠 12px。当您释放单击时,选取框现在应该是 60px 宽 72px 高,矩形的左侧未被选中。

  3. With the marquee still on the shape, make sure the shape layer is selected in the Layers panel; then click the Add layer mask button at the bottom of the Layers panel (it is next to the Layer Styles button) to create a mask. This gives the shape the appearance of having the upper- and lower-right corners rounded, but the left side squared (see Figure 6-20).

    9781430246206_Fig06-20.jpg

    图 6-20。从左到右,圆角矩形,矩形周围的选取框选择,从左侧取消选择的 12px,以及最终的遮罩形状

  4. 对此形状应用与提交按钮相同的阴影;然后使用选中复选框的自定义形状工具,以浅色#FBF7E7 绘制一个复选框,以深色形状为中心。这给了你一个回答按钮。

  5. Position the room link and end this session buttons in the right-hand column, and the presenter view is good to go (see Figure 6-21).

    9781430246206_Fig06-21.jpg

    图 6-21。演示者视图

更小的屏幕布局(以及为什么不在这里设计它们)

在上一章中,我们花了很多时间讨论一个移动友好的 web 应用相对于一套本地应用的优势。那么为什么现在不设计手机版呢?

主要原因是移动设计本质上太不稳定,不适合传统设计。这需要更多的动手操作,尝试看看的方法,这在 Photoshop 中会花费很长时间。 6

由于这种特殊布局的简单性,另一个不做手机专用布局的原因是为了节省时间。如果在 CSS 中进行修改很容易,那么最好把时间花在编码上。不需要加倍努力。

摘要

在本章中,您应用了前几章的所有规划,并为应用创建了一个设计。因为你是有组织的,你提前考虑过,你的 PSD 包含了可以用 CSS 非常接近地复制的样式和字体。

在下一章——终于——你将开始编写这个应用。更具体地说,您将构建前端,包括高级 CSS,使这种设计适应任何屏幕大小。

1

2

3

T2T4http://www.fontpalace.com/font-details/News+Gothic+BT/

5

6Adobe 似乎也知道这一点,因为他们正在开发一种新工具来解决这个问题:http://html.adobe.com/edge/reflow/。**

七、创建 HTML 和 CSS 标记

现在设计已经准备好了,您可以开始编码了。在这一章中,你将把你创建的 PSD 转换成一个 web 可用的 HTML 和 CSS 布局。您还将实现 CSS3 媒体查询,以确保布局在平板电脑和手持设备大小的屏幕上看起来不错。

从基础开始:设置 HTML5 文档

在写一行代码之前,让我们提醒自己正在创建的设计(见图 7-1 )。

9781430246206_Fig07-01.jpg

图 7-1。应用外观的提示

现在,您可以在项目的根文件夹中创建一个新的 HTML 文档。将其命名为index.html,并插入doctype和其他必需的元素:

<!doctype html>
<html lang="en">

<head>

<meta charset="utf-8" />

<title>Realtime Q&amp;A</title>

</head>

<body> 

</body>

</html>

准备好字体

接下来,你需要设计的字体。因为 Cooper Black 和 News Gothic 不是 Tahoma 和 Georgia 等传统的 web 安全字体,所以您需要使用 CSS 的@font-face功能来加载它们。

然而,Cooper Black 和 News Gothic 并没有在开放字体许可下发布, 1 ,这意味着简单地在网站上使用@font-face字体是不合法的。但幸运的是,由于 Fonts.com 等公司的出现,这不是一个问题,这些公司(收取合理的费用)将处理字体嵌入的法律问题,让我们继续进行设计。

对于这个应用,在 Fonts.com 创建一个免费帐户,在“管理 Web 字体”部分创建一个新项目,并将 Cooper Black Regular 和 News Gothic No. 2 系列字体(搜索“News Gothic”)添加到一个项目中(参见图 7-2 )。将您的开发域(即使是本地的)添加到项目中;那就发表吧。使用 JavaScript 嵌入选项,因为这是免费帐户唯一可以使用的选项。

9781430246206_Fig07-02.jpg

图 7-2。Fonts.com 项目详情

将新的<script>标签添加到<head>部分的index.html中:

<head>

<meta charset="utf-8" />

<title>Realtime Q&amp;A</title>

<!--Fonts via fonts.com-->
<script type="text/javascript" src="[`fast.fonts.com/jsapi/a09d5d16-57fd-447d-a0f6-73443033d2de.js"></script`](http://fast.fonts.com/jsapi/a09d5d16-57fd-447d-a0f6-73443033d2de.js"></script)>

</head>

接下来,在应用的根目录下创建一个名为assets的子目录,并在其中创建另一个名为styles的子目录。在里面,创建一个名为main.css的新样式表。

main.css里面,添加一个注释块,上面有来自 Fonts.com 的font-family规则,以备后用:

/**
 * Styles for the Realtime Q&A app
 */

/*
fonts.com font-family rules.

Cooper Black
font-family:'Cooper Black W01';

News Gothic:
light: font-family:'NewsGothicNo.2W01-Light 710401';
light-italic: font-family:'NewsGothicNo.2W01-Light 710404';
roman: font-family:'NewsGothicNo.2W01-Roman';
italic: font-family:'News Gothic No.2 W01 It';
demi: font-family:'NewsGothicNo.2W01-Mediu 710407';
demi-italic: font-family:'NewsGothicNo.2W01-Mediu 710410';
bold: font-family:'NewsGothicNo.2W01-Bold';
bold-italic: font-family:'NewsGothicNo.2W01-BoldI';
*/

index.html<head>部分的 Fonts.com 脚本标签下包含新的样式表:

<head>

<meta charset="utf-8" />

<title>Realtime Q&amp;A</title>

<!-- Fonts via fonts.com -->
<script type="text/javascript" src="[`fast.fonts.com/jsapi/a09d5d16-57fd-447d-a0f6-73443033d2de.js">
</script`](http://fast.fonts.com/jsapi/a09d5d16-57fd-447d-a0f6-73443033d2de.js"></script)>

<!--Main site styles-->
<link rel="stylesheet" href="./assets/styles/main.css" />

</head>

@FONT-FACE 和 SAAS

虽然看起来它仅仅存在了一两年,但实际上它早在 1998 年就随着 CSS2 规范被引入了。然而,由于缺乏支持、浏览器不兼容问题,以及最重要的法律问题,它直到最近才被真正使用。

使用@font-face创建一个指向字体的指针,它允许浏览器下载并使用它在用户机器上呈现字体。这对设计来说很棒,但是它为任何人简单地非法下载字体打开了大门。可以预见,铅字铸造厂对此并不满意。

然而,近年来,在提高网络字体的安全性方面取得了长足的进步。除了 web 字体的新格式,如嵌入式开放类型(EOT)和 web 开放字体格式(WOFF),提供 Web 字体嵌入软件作为服务的公司如雨后春笋般涌现(SaaS)。

使用 SaaS 有很多好处,但只有一个缺点(那根本不是缺点)。主要优势如下:

  • 您无需购买即可使用该字体。
  • 你不必再为了保护你自己和你的客户而在模糊的法律水域中航行。
  • 跨浏览器字体嵌入的有限性已经为您处理好了。

使用 SaaS 进行字体嵌入的唯一“缺点”是它不是免费的。但是如果你看看这些数字,使用 SaaS 实际上是一笔巨大的交易。例如,流行的 Futura 字体可以从 MyFonts 3 以每种风格 24.95 美元或 445.50 美元购买全家(每月限 10,000 次浏览量)的网络字体购买。

*Fonts.com4每月收费 10 美元来嵌入整个 Futura 系列(以及其库中的任何其他字体),每月浏览量高达 25 万次。

当我们做数学计算时,这意味着如果唯一嵌入的字体是 Futura 系列,那么使用 SaaS 将需要超过 3 年半的时间来购买网络字体许可。

考虑到你的页面浏览量是它的 25 倍,而且你可能会在这项服务中使用不止一种字体,很明显 SaaS 是一笔大买卖。

网络字体 SaaS 选项

对于这个应用,我们使用 Fonts.com 加载库珀黑色和新闻哥特式到我们的设计。有许多选项可供选择,但以下是一些最受欢迎的选项:

  • http://fontdeck.com/
  • http://fonts.com/
  • https://typekit.com/

如果你选择使用 Fonts.com 以外的软件,你仍然可以顺利完成本书中的应用,但是请注意font-family名称可能会有所不同。

image 注意Fonts.com 免费账户要求在你的应用中放置一个横幅,JavaScript include 会自动做到这一点。Fontdeck 的免费帐户将允许您开发本书中的应用,而不会增加任何成本。他们只在一个网站被“激活”时收费,他们认为当超过 20 个唯一的 IP 地址需要访问该网站时才收费。

公共元素

像大多数其他应用一样,这个应用有一些不随页面变化的通用元素。让我们从发展这些开始。

标题标记

这个应用将提供的每个视图都有顶部的标题。这在结构上很简单:它是一个盒子,里面有应用标题和标语。

使用 HTML5 <header>元素,将标题标记添加到index.html<body>部分:

<body>

<header>
    <h1>Realtime Q&amp;A</h1>
    <p class="tagline">
        A live feedback system for classes, presentations, and conferences.
    </p><!--/.tagline-->
</header>

</body>

image 提示在带有类或 id 的元素的末尾使用注释有助于提高可扫描性,尤其是在处理嵌套了相同标签的嵌套元素或布局时,比如<div>元素。这些注释完全是可选的。

如果你在浏览器中查看,它在语义上是有意义的(见图 7-3 )。这应该是应用的一个次要目标:如果所有的样式都被撕掉了,它还清晰可辨吗?

9781430246206_Fig07-03.jpg

图 7-3。未样式化的标题标记

页脚标记

与页眉类似,页脚在语义上也很简单。它分解成一个盒子,里面有法律副本和几个链接。因为这样的复制在列表中是有意义的,所以让我们使用一个无序列表在<footer>元素中显示它。将粗体代码添加到index.html : 的正文中

<body>

<header>
    <h1>Realtime Q&amp;A</h1>
    <p class="tagline">
        A live feedback system for classes, presentations, and conferences.
    </p><!--/.tagline-->
</header>

<footer>
    <ul>
        <li class="copyright">
            &copy; 2013 Jason Lengstorf &amp; Phil Leggetter
        </li><!--/.copyright-->
        <li>
            Part of <em>Realtime Web Apps: HTML5 Websockets, Pusher, and the
            Web&rsquo;s Next Big Thing</em>.
        </li>
        <li>
            <a href="[`amzn.to/XKcBbG">Get`](http://amzn.to/XKcBbG">Get)the Book</a> |
            <a href="[`cptr.me/UkMSmn">Source`](http://cptr.me/UkMSmn">Source)Code (on GitHub)</a>
        </li>
    </ul>
</footer>

</body>

在浏览器中重新加载index.html。页脚信息以逻辑方式显示(参见图 7-4 )。

9781430246206_Fig07-04.jpg

图 7-4。添加了无样式的页脚标记

款式

标记就绪后,就可以开始设计元素的样式了。从基础开始,添加字体规则。每个元素都将在em s 中设置其字体大小,以便在稍后媒体查询时更加灵活。诀窍是将主体font-size设置为px值,对所有其他元素使用相对大小。如果你以后需要增加或减少字体大小,你需要做的就是调整主体设置,整个设计会相应地调整。

以设计为指导,为页眉和页脚中的每个元素设置颜色、大小和字母间距。使用粗体或斜体字体时,不要忘记将字体重置为正常样式和粗细;如果你忘记了,浏览器会将自己的粗体或斜体应用到已经粗体或斜体的文本中,这在大多数情况下看起来很糟糕。当你完成了这些,CSS 将看起来如下:

/**
 * Styles for the Realtime Q&A app
 */

/*
fonts.com font-family rules.

Cooper Black
font-family:'Cooper Black W01';

News Gothic:
light: font-family:'NewsGothicNo.2W01-Light 710401';
light-italic: font-family:'NewsGothicNo.2W01-Light 710404';
roman: font-family:'NewsGothicNo.2W01-Roman';
italic: font-family:'News Gothic No.2 W01 It';
demi: font-family:'NewsGothicNo.2W01-Mediu 710407';
demi-italic: font-family:'NewsGothicNo.2W01-Mediu 710410';
bold: font-family:'NewsGothicNo.2W01-Bold';
bold-italic: font-family:'NewsGothicNo.2W01-BoldI';
*/

/* Basic Font Styles
 ----------------------------------------------------------------------------*/

body {
    font: 18px/24px 'NewsGothicNo.2W01-Roman';
    color: #1f1b0c;
    letter-spacing: .06em;
}

h1 {
    font-family: 'Cooper Black W01';
    font-weight: normal;
}

h1 {
    margin: 0;
    color: #fbf7e7;
    font-size: 6em;
    line-height: 1em;
    letter-spacing: -.1em;
}

.tagline {
    font-family: 'NewsGothicNo.2W01-Light 710401';
    font-size: 1.1em;
    line-height: 1em;
    color: #fbf7e7;
    letter-spacing: .12em;
}

a {
    font-family: 'NewsGothicNo.2W01-Mediu 710407';
    color: #e06f00;
    text-decoration: none;
}

a:active,a:hover,a:focus {
    text-decoration: underline;
    outline: none;
}

footer li {
    font-family: 'NewsGothicNo.2W01-Light 710401';
    font-size: .75em;
    line-height: 1em;
    letter-spacing: .04em;
    color: #fbf7e7;
}

footer li em {
    font-family: 'NewsGothicNo.2W01-Light 710404';
    font-style: normal;
}

image 注意现在为h1设置了两个规则。这是有意的,因为在设计的后期,会有其他元素添加到第一个规则中,这些元素不应该从第二个规则集中接收样式。

如果此时将index.html加载到浏览器中,在添加深色背景之前,它看起来不会正确(参见图 7-5 )。

9781430246206_Fig07-05.jpg

图 7-5。仅应用字体样式规则的标记

要纠正这一点,现在您需要添加布局规则,以给出背景颜色、对齐方式和其他框模型规则。添加新的布局代码以完成页眉和页脚样式:

/* Layout
 ----------------------------------------------------------------------------*/

html { background-color: #fbf7e7; }

body { margin: 0; }

header,footer {
    -webkit-box-shadow: 0 0 10px rgba(31, 27, 12, .3);
            box-shadow: 0 0 10px rgba(31, 27, 12, .3);
}

header,footer {
    overflow: hidden;
    background: #1f1b0c;
    margin: 0;
    padding: 1em;
    text-align: center;
}

header {
    margin-bottom: 3em;
    padding: 3em 0 2em;
}

header h1,header p {
    width: 960px;
    margin: 0 auto;
    padding: 0;
}

header h1 { margin-bottom: .25em; }

footer { margin-top: 6em; }

footer ul {
    overflow: hidden;
    width: 960px;
    margin: 0 auto;
    padding: 0;
}

footer li {
    float: right;
    margin-left: 1em;
    list-style: none;
}

footer li.copyright { float: left; margin-left: 0; }

这段代码的大部分都是不言自明的,但是有几条规则值得注意。方框阴影仅使用-webkit-前缀。这是因为所有其他浏览器现在都支持标准的框阴影规则(甚至是 IE9),所以不再需要包含规则的前缀版本来使其工作。事实上,-webkit-前缀只需要添加对 Safari、iOS 和 Android 旧版本的支持。因为旧版本的 iOS 和 Android 并不少见,所以保持这个规则是个好主意。

box-shadow规则还使用了从 CSS3 开始新增的rgba。这允许您设定颜色的 alpha(或不透明度)。使用的 RGB 值与站点的深色相匹配。

最后,一个解决“clearfixes”需求的技巧:将包含元素设置为overflow: hidden,它将增长到包含浮动元素。这适用于所有的浏览器,所以这是一个消除大量非语义元素的非常简单的技巧。这个技巧用在页脚元素上,强制<footer>元素在浮动的<li>元素周围正确地应用填充。

重新加载浏览器,查看完整的页眉和页脚(参见图 7-6 )。

9781430246206_Fig07-06.jpg

图 7-6。样式化的页眉和页脚

使页眉和页脚响应迅速

完成页眉和页脚的最后一步是添加媒体查询,这将调整它们在平板电脑和手持设备上的显示。

您不需要做任何花哨的事情,比如检查设备方向或像素密度,而是简单地根据视口宽度调整布局。这种方法的原因是,在较小的屏幕上不需要发生任何花哨的事情;布局只需要稍微调整一下,以适应所提供的屏幕空间。

为了简单起见,将仅对布局进行两项调整:宽度在960px以下的设备(平板电脑)和宽度在768px以下的设备(手持设备)。

对于标题,需要改变的只是元素的字体大小和最大宽度。更改填充有助于防止布局看起来过于宽敞。

页脚需要同样的调整。此外,它还需要关闭列表元素的浮动,以便它们可以居中。

将媒体添加到main.css的底部,以调整较小屏幕的页眉和页脚:

/* Media queries
 ----------------------------------------------------------------------------*/

@media screen and (max-width: 960px)
{

    header h1,header p { width: 760px; }

    header { padding: .75em 0 1.2em; }

    footer { margin-top: 4em; padding: 0;}

    footer ul { width: 740px; }

    footer li,footer li.copyright { float: none; margin: .75em 0;}

}

@media screen and (max-width: 768px)
{

    header h1,header p {
        width: 90%;
        min-width: 300px;
    }

    header { padding-bottom: .75em; }

    header h1 { font-size: 2.4em; }

    footer { margin-top: 3em; }

    footer ul { width: 300px; }

}

在浏览器中重新加载文件。乍一看,似乎什么都没有改变,但是如果你调整浏览器窗口的大小,你会看到元素根据不同的屏幕尺寸进行了调整(见图 7-7 )。

9781430246206_Fig07-07.jpg

图 7-7。不同屏幕尺寸的页眉和页脚

开发主页视图

应用的框架准备好了,你现在可以从主页开始插入各个视图了。

编写标记

主页由两个表单组成,所以让我们从两个表单中较简单的一个开始,了解基本情况。

“加入房间”表单有一个标题、一个简短的广告词、一个带标签的输入和一个提交按钮。将以下标记添加到index.html, in between the <header> and <footer> tags,以创建该表单:

<section>

    <form id="attending">
        <h2>Attending?</h2>
        <p>Join a room using its ID.</p>
        <label>
            What is the room's ID?
            <input type="text" name="room_id" />
        </label>
        <input type="submit" value="Join This Room" />
    </form><!--/#attending-->

</section>

接下来,使用相同格式生成“创建房间”表单,它的标记方式几乎相同。将这个<form>元素直接添加到我们刚刚添加的前一个“attending”表单元素之后:

<form id="presenting">
    <h2>Presenting?</h2>
    <p>Create a room to start your Q&amp;A session.</p>
    <label>
        Tell us your name (so attendees know who you are).
        <input type="text" name="presenter-name" />
    </label>
    <label>
        Tell us your email (so attendees can get in touch with you).
        <input type="email" name="presenter-email" />
    </label>
    <label>
        What is your session called?
        <input type="text" name="session-name" />
    </label>
    <input type="submit" value="Create Your Room" />
</form><!--/#presenting-->

这段代码没有什么值得注意的,除了使用type="email"作为演示者的电子邮件输入。保存它并重新加载浏览器;你现在有了主视图的两种形式,尽管没有样式化(见图 7-8 )。

9781430246206_Fig07-08.jpg

图 7-8。主视图的所有标记都已就位,但需要样式化

实现 CSS

更新现有的包含字体系列和字体粗细的h1规则,使字体系列和字体粗细也适用于h2标签。还要为二级标题添加附加规则;一般段落;以及表单、输入和标签:

/* Basic Font Styles
 ----------------------------------------------------------------------------*/
/* Update */
h1, h2 {
    font-family: 'Cooper Black W01';
    font-weight: normal;
}

/* Unchanged */ h1 {    margin: 0;    color: #fbf7e7;    font-size: 6em;    line-height: 1em;    letter-spacing: -.1em;}/* New rules */

h2 { text-shadow: 0 0 10px rgba(31, 27, 12, .3); }

h2 {
    margin: 0 0 .5em;
    font-size: 2.75em;
    line-height: 1em;
    letter-spacing: -.08em;
}

p {
    text-align: center;
}

form p {
    margin: 0 0 1em;
    padding: 0;
    font-size: 1.375em;
}

label {
    font-family: 'NewsGothicNo.2W01-Bold';
    font-size: .75em;
    line-height: 1.25em;
    letter-spacing: .04em;
}

input {
    font-family: 'Cooper Black W01';
    color: #fbf7e7;
    background-color: #1f1b0c;
    border-radius: 6px;
    border: none;
    font-size: 1.75em;
    line-height: 1em;
    letter-spacing: -.08em;
    text-shadow: 0 0 10px rgba(31, 27, 12, .3);
}

label input {
    font-family: 'NewsGothicNo.2W01-Light 710401';
    font-size: 1.75em;
    letter-spacing: .08em;
    color: #1f1b0c;
    background-color: #fff;
}/* Existing rules e.g. .tagline */

保存后,主视图应有适当的字体(见图 7-9 )。

9781430246206_Fig07-09.jpg

图 7-9。应用了字体样式的主视图,但没有布局规则

布局规则相当简单:“加入房间”表单向右浮动,“创建房间”表单向左浮动,标签和输入需要垂直堆叠。通过在main.css : 的“布局”部分的第一个footer规则之前插入如下所示的代码来实现这些规则

section {
    width: 960px;
    margin: 0 auto;
    overflow: hidden;
}

form {
    float: left;
    width: 460px;
    text-align: center;
}

form#attending { float: right; }

label {
    display: block;
    width: 430px;
    margin: 0 auto 1em;
    text-indent: 2px;
    text-align: left;
}

input {
    margin: .25em 0 0;
    padding: .375em .875em;
}

label input {
    display: block;
    width: 400px;
    margin: 0;
    padding: .375em 15px;
    border: 2px solid #1f1b0c;
    -webkit-border-radius: 6px;
            border-radius: 6px;
    -webkit-box-shadow: inset 2px 2px 14px rgba(31, 27, 12, .3);
            box-shadow: inset 2px 2px 14px rgba(31, 27, 12, .3);
}

保存这些更改并在浏览器中重新加载页面。此时,主视图已接近完成(见图 7-10 )。

9781430246206_Fig07-10.jpg

图 7-10。主视图现在看起来像实体模型

为活动和悬停的表单元素创建样式

因为表单是交互式的,所以当用户悬停、单击和切换表单元素时,为他们提供视觉反馈是一个好主意。为此,您需要为输入的:active:hover:focus状态添加样式。

使用高亮颜色#E06F00,改变活动输入的边框颜色以指示用户当前关注的位置,并使提交按钮在活动或悬停时变为橙色。包括以下代码来实现这一点:

/* Highlights
 ----------------------------------------------------------------------------*/
input:active,input:hover,input:focus {
    background-color: #e06f00;
    outline: none;
}

input::-moz-focus-inner { border: 0; }

label input:active,label input:focus {
    border-color: #e06f00;
    background-color: #fff;
    outline: none;
}

label input:hover { background-color: #fff; }

这段代码覆盖了浏览器的默认行为,并用一个自定义突出显示来替换它。值得注意的是input::-moz-focus-inner规则;这解决了 Firefox 中的一个问题,当它处于活动状态时,会导致输入在输入内部出现一个小虚线。

image 警告如果你覆盖了默认的浏览器样式,确保用你自己的样式替换它们。使用键盘浏览网页的用户依靠:focus:active状态来查看光标当前停留的位置,因此完全移除这些状态将对用户体验产生负面影响。

覆盖样式后,在浏览器中重新加载页面,并使用 Tab 键在表单中导航。现在,当输入处于活动状态时,它们会以橙色突出显示(参见图 7-11 ),当它们有焦点或悬停在上面时,提交按钮会变成橙色(参见图 7-12 )。

9781430246206_Fig07-11.jpg

图 7-11。具有焦点的文本输入现在用橙色突出显示

9781430246206_Fig07-12.jpg

图 7-12。当鼠标悬停或聚焦时,提交按钮变成橙色

添加媒体查询

媒体对主页的查询仍然相当简单。在平板电脑上,如果表格缩小了一点,你仍然可以把它们并排放在一起,而在手持设备上,它们应该一个叠一个。

“加入房间”表单应该放在最上面,因为它更短,也更容易被使用(参加的人可能比出席的人多)。

以下代码为较小的视口添加了额外的规则:

@media screen and (max-width: 960px)
{

    header h1,header p { width: 760px; }

    header { padding: .75em 0 1.2em; }

    section { width: 740px; }

    p { margin: 0 0 2em; padding: 0; }

    header>p,section>p { font-size: .875em; }

    form { width: 340px; padding: 0 8px; }

    form p { font-size: 1em; }

    label { width: 100%; }

    input { font-size: 1.5em; }

    label input { width: 91%; }

    footer { margin-top: 4em; padding: 0;}

    footer ul { width: 740px; }

    footer li,footer li.copyright { float: none; margin: .75em 0;}

}

@media screen and (max-width: 768px)
{

    header h1,header p,section {
        width: 90%;
        min-width: 300px;
    }

    header { padding-bottom: .75em; }

    header h1 { font-size: 2.4em; }

    form,form#attending {
        float: none;
        width: 90%;
        margin: 0 auto 3em;
    }

    form p { font-size: .75em; }

    label input { width: 88%; font-size: 1.6em; }

    footer { margin-top: 3em; }

    footer ul { width: 300px; }

}

保存并重新加载页面;然后改变浏览器大小,查看布局是否适应(参见图 7-13 )。

9781430246206_Fig07-13.jpg

图 7-13。主视图,包含响应性布局规则

为与会者开发活动房间视图

下一步是为活动房间视图创建标记,因为它将被与会者看到。这是他们提问和投票的地方。

编写标记

首先,抓取在index.html中使用的相同的页眉和页脚标记,并将其保存到一个名为attendee-active.html : 的新文件中

<!doctype html>
<html lang="en">

<head>

<meta charset="utf-8" />

<title>Realtime Q&amp;A</title>

<!-- Fonts via fonts.com -->
<script type="text/javascript" src="[`fast.fonts.com/jsapi/a09d5d16-57fd-447d-a0f6-73443033d2de.js"></script`](http://fast.fonts.com/jsapi/a09d5d16-57fd-447d-a0f6-73443033d2de.js"></script)>

<!-- Main site styles -->
<link rel="stylesheet" href="./assets/styles/main.css" />

</head>

<body>

<header>
    <h1>Realtime Q&amp;A</h1>
    <p class="tagline">
        A live feedback system for classes, presentations, and conferences.
    </p><!--/.tagline-->
</header>

<section>

</section>

<footer>
    <ul>
        <li class="copyright">
            &copy; 2013 Jason Lengstorf &amp; Phil Leggetter
        </li><!--/.copyright-->
        <li>
            Part of <em>Realtime Web Apps: HTML5 Websockets, Pusher, and the
            Web&rsquo;s Next Big Thing</em>.
        </li>
        <li>
            <a href="http://amzn.to/XKcBbG">Getthe Book</a> |
            <a href="http://cptr.me/UkMSmn">SourceCode (on GitHub)</a>
        </li>
    </ul>
</footer>

</body>

</html>

该视图的内容分为三个部分:

  • 标题,包含房间名称和演示者信息
  • “提出问题”表格
  • 问题列表

前两个标记元素并不奇怪,所以让我们先把它们去掉。在<section>中添加两个附加元素:

<section>

    <header>
        <h2>Realtime Web Apps &amp; the Mobile Internet</h2>
        <p>
            Presented by Jason Lengstorf
            (<a href="mailto:jason@lengstorf.com">email</a>)
        </p>
    </header>

    <form id="ask-a-question">
        <label>
            If you have a question and you don't see it below, ask it here.
            <input type="text" name="new-question" tabindex="1" />
        </label>
        <input type="submit" value="Ask" tabindex="2" />
    </form><!--/#ask-a-question-->

</section>

使用 HTML5 的数据属性

这些问题将以一个无序列表的形式出现,但是有一个变化:您将使用 HTML5 data-属性,而不是为投票计数创建一个额外的元素。

这有两个目的:

  • jQuery 内置了对访问该属性的支持
  • CSS 可以使用这个属性来生成对文档不重要的内容

image 注意“不重要”在前面的陈述中是指投票计数对于所显示的信息并不重要。出于这个原因,它被排除在标记之外,而是由 CSS 显示,从而保持标记的整洁和语义。

将问题无序列表标记添加到现有<section>的底部:


<ul id="questions">

    <li id="question-1"
        data-count="27">

        **<p>**
            **What is the best way to implement realtime features today?**
        **</p>**

        **<form class="vote">**
            **<input value="I also have this question."**
                   **type="submit"  />**
        **</form>**

    **</li>**`<!--`**/#question-1**`-->`

    **<li id="question-2"**
        **data-count="14">**

        **<p>**
            **Does this work on browsers that don't support the**
            **WebSockets API?**
        **</p>**

        **<form class="vote">**
            **<input value="I also have this question."**
                   **type="submit"  />**
        **</form>**

    **</li>**`<!--`**/#question-2**`-->`

**</ul>**`<!--`**/#questions**`-->`

标记就绪后,在浏览器中加载attendee-active.html`。看起来不太好,但所有的部分都在适当的位置(见图 7-14 )。

9781430246206_Fig07-14.jpg

图 7-14。未样式化的活动与会者视图

实现 CSS

首先,通过在main.css : 的“基本字体样式”部分的第一个页脚规则之前插入以下代码,添加问题和标题的字体样式。

section header h2 {
    font-size: 1.5em;
    line-height: 1.125em;
    letter-spacing: -.06em;
}

#questions li {
    font-size: 1.33em;
    letter-spacing: .1em;
}

接下来,您需要添加基本的布局规则。有很多,所以在main.css中创建一个新的部分,紧接在现有的“布局”部分之后:

/* Questions View
 ----------------------------------------------------------------------------*/

section header {
    background: transparent;
    float: right;
    width: 340px;
    margin: 0;
    padding: 0;
    box-shadow: none;
    overflow: visible;
}

section header h2 {
    margin: 0 0 .5em;
    text-align: left;
}

section header p {
    width: auto;
    margin: 0;
    text-align: left;
}

form#ask-a-question,#questions {
    width: 596px;
    margin: 0;
    padding: 0;
    overflow: hidden;
}

#questions { padding-bottom: 1em; }

#ask-a-question label,#ask-a-question>input { float: left; }

#ask-a-question label { width: 460px; }

#ask-a-question label input {
    width: 430px;
    height: 1.7em;
    margin: 0;
    padding-left: 15px;
    padding-right: 15px;
}

#ask-a-question input {
    height: 1.55em;
    margin: 0.5em 0 0 0.5em;
    padding: 0.1em 0.75em;
}

#questions li {
    position: relative;
    list-style: none;
    margin: 0;
    padding: 1em 0 1em;
    overflow: hidden;
    -webkit-box-shadow: 0  12px 16px -16px rgba(31, 27, 12, .3),
                        0 -12px 16px -16px rgba(31, 27, 12, .3);
            box-shadow: 0  12px 16px -16px rgba(31, 27, 12, .3),
                        0 -12px 16px -16px rgba(31, 27, 12, .3);
}

#questions p {
    float: right;
    width: 77%;
    margin: .75em 0;
    padding: 0;
    text-align: left;
}

#questions .vote {
    position: relative;
    display: block;
    width: 76px;
    height: 76px;
    margin: 0 0 0 2em;
    padding: 0;
    overflow: hidden;
}

这些规则都是相当基本的;他们解决了<header>元素与文本颜色相同的问题,将各部分浮动到适当的位置,并在适当的地方添加边距和间距,以使元素正确布局(参见图 7-15 )。

9781430246206_Fig07-15.jpg

图 7-15。问题页面的部分完整布局

切片 PSD 和创建精灵

布局基本完成后,我们需要将按钮的图像放入网站。这将需要切片 PSD 并创建一个 sprite,这是一个由较小的图像组成的大图像。精灵用于减少请求的数量和网站的整体下载量。

在与会者问题视图中打开 Photoshop 中的 PSD 并抓取切片工具。在“向上投票”按钮周围画一个 76 px 乘 76 px 的切片。确保它水平和垂直居中。

在第二个向上投票按钮,进入黑色圆形的图层样式,用我们的高亮颜色#E06F00添加一个颜色叠加。这将是按钮的悬停和活动状态。

在按钮顶部以 76 px 乘 76 px 绘制另一个切片(参见图 7-16 )。

9781430246206_Fig07-16.jpg

图 7-16。PSD 中的按钮带有切片

存储为 Web 所用(command + option + shift + S 或“文件”菜单中的“存储为 Web 所用…”)。将两个按钮片段保存为 PNG 文件。您可以随意命名它们,因为您将在下一步将它们组合成不同的图像。

为了准备演示者视图,通过绘制 78 px 宽 88 px 高的切片,对回答按钮及其悬停状态做同样的事情(参见图 7-17 )。

9781430246206_Fig07-17.jpg

图 7-17。PSD 中的接听按钮带有切片

接下来,创建一个 154 像素宽,176 像素高的新 PSD。将背景设置为浅色#FBF7E7

将您刚刚创建的四个 png 放入文档,将“关闭”投票按钮放在左上角,“打开”投票按钮放在左下角,“关闭”回答按钮放在右上角,“打开”回答按钮放在右下角(参见图 7-18 )。

9781430246206_Fig07-18.jpg

图 7-18。Photoshop 中的精灵

最后一步是将这张图片保存到网上。这一次,将其保存为 JPG,并在不明显损失质量的情况下尽可能降低质量。在该图像的情况下,70的质量设置是好的。

要保存该图像以供项目使用,请在assets文件夹中创建一个名为images的新子目录;然后将此图像保存为sprite.jpg

回到main.css,让我们使用新的精灵来设计投票按钮。将以下代码添加到“问题视图”部分的底部:

#questions .vote input[type=submit] {
    margin: 0;
    width: 100%;
    height: 100%;
    cursor: pointer;
    text-indent: -9999px;
    background: url(../img/sprite.jpg) left top no-repeat;
    -webkit-box-shadow: none;
            box-shadow: none;
}

#questions .vote input:active,
#questions .vote input:hover,
#questions .vote input:focus {
    background-position: left bottom;
}

这段代码将输入设置为像一个小的查看窗口一样工作,只显示 sprite 的一部分。在悬停时,或者当用户切换到输入时,精灵移动,在输入的查看窗口中显示其自身的不同部分。这节省了加载“over”图像的额外 HTTP 请求,从而改善了加载时间和整体用户体验。

在浏览器中保存并重新加载文档。输入现在看起来像你设计的按钮,悬停或点击按钮会使其以橙色高亮显示(见图 7-19 )。

9781430246206_Fig07-19.jpg

图 7-19。样式化按钮,包括其高亮显示状态

使用:在之前

这个视图的最后一步是从data-count属性中检索投票数。这是使用:before伪元素和content规则完成的,它允许您传入一个要在伪元素中显示的属性名。使用data-count属性,将其向左移动,并使用以下代码将其垂直放置在中心位置(添加到main.css) : 中“问题视图”部分的底部)

#questions li:before {
    content: attr(data-count);
    position: absolute;
    left: 0;
    top: 50%;
    margin-top: -.5em;
}

保存并重新加载;现在票数显示在每个问题的左侧(见图 7-20 )。

9781430246206_Fig07-20.jpg

图 7-20。投票数显示在问题的左侧

最后,您需要使字体与设计相匹配,因此在main.css的“基本字体样式”部分,为已经应用于h1h2的字体规则添加一个用于伪元素的选择器:

h1,h2,#questions li:before {
    font-family: 'Cooper Black W01';
    font-weight: normal;
}

现在,如果重新加载,字体看起来是正确的(见图 7-21 )。

9781430246206_Fig07-21.jpg

图 7-21。风格化的投票计数

添加媒体查询

较小屏幕的布局相当简单。在平板电脑上,房间描述移动到布局的顶部,表单和问题位于下方,形成一列布局。

在手持设备上,一列布局仍然存在,投票计数移到投票按钮上方,以节省更多的水平空间。

更新main.css中的“媒体查询”部分,以匹配粗体显示的代码,从而实施更改:

@media screen and (max-width: 960px)
{

    header h1,header p { width: 760px; }

    header { padding: .75em 0 1.2em; }

    section header {
      float: none;
      width: 680px;
        margin: 0 auto 1.5em;
      overflow: hidden;
    }

    p { margin: 0 0 2em; padding: 0; }

    header>p,section>p { font-size: .875em; }

    form { width: 340px; padding: 0 8px; }

    form#ask-a-question { float: none; width: 680px; margin: 0 auto 1em; }

    form p { font-size: 1em; }

    label { width: 100%; }

    #ask-a-question label { width: 80%; }

    input { font-size: 1.5em; }

    #ask-a-question input { font-size: 1.75em; }

    label input,#ask-a-question label input { width: 91%; }

    #questions { margin: 0 auto; }

    footer { margin-top: 4em; padding: 0;}

    footer ul { width: 740px; }

    footer li,footer li.copyright { float: none; margin: .75em 0;}

}

@media screen and (max-width: 768px)
{

    header h1,header p {
        width: 90%;
        min-width: 300px;
    }

    section,section header,form#ask-a-question,#questions { width: 300px; }

    header { padding-bottom: .75em; }

    header h1 { font-size: 2.4em; }

    form,form#attending,form#ask-a-question {
        float: none;
        width: 90%;
        margin: 0 auto 3em;
    }

    form#ask-a-question { overflow: visible; }

    form p { font-size: .75em; }

    label input { width: 88%; font-size: 1.6em; }

    #ask-a-question label { width: 270px; }

    #ask-a-question label input { width: 87%; }

    #ask-a-question input {
        float: none;
        margin: 0 auto;
    }

    #questions li { font-size: 1.125em; line-height: 1.125em; }

    #questions li:before { top: 20px; left: 24px; margin-top: 0; }

    #questions .vote { margin: 20px 0 0;}

    #questions p { width: 210px; margin: 0; }

    footer { margin-top: 3em; }

    footer ul { width: 300px; }

}

保存、重新加载和调整浏览器窗口的大小。布局现在可以响应了(见图 7-22 )。

9781430246206_Fig07-22.jpg

图 7-22。以多种视窗尺寸查看问题

为与会者开发封闭房间视图

下一步是为被演示者关闭的房间创建视图。与会者将不再能够投票,将会有一个通知让他们知道会议室已经关闭,他们可以通过电子邮件联系演示者。

在 app 的根目录下新建一个名为attendee-closed.html的文件;这是保存该步骤标记的地方。

争取尽可能少的新加价

因为编写更少的代码总是更好,所以对标记所做的唯一更改是用封闭房间通知替换“提问”表单,并在问题列表中添加一个“封闭”类。将attendee-active.html的内容复制到新的attendee-closed.html文件中,将“提问”表单替换为关闭通知标记,并添加class="closed"属性,如下图所示:

<h3>This session has ended.</h3>
<p>
    If you have a question that wasn't answered, please
    <a href="mailto:jason@copterlabs.com">email the presenter</a>.
</p>

<ul id="questions" class="closed">

    <!—leave existing elements here -->

</ul><!--/#questions-->

保存后,将文档加载到浏览器中,显示文档几乎准备就绪,无需更改(见图 7-23 )。

9781430246206_Fig07-23.jpg

图 7-23。未应用新样式的关闭视图

添加样式

调整标题和正文样式,更新两个现有规则以包括h3,添加一个新的h3规则,并修改p,如下面main.css : 的“基本字体样式”部分所示

h1,h2,h3,#questions li:before {
    font-family: 'Cooper Black W01';
    font-weight: normal;
}

h2,h3 { text-shadow: 0 0 10px rgba(31, 27, 12, .3); }

h3 {
    margin: 0 365px .75em 0;
    font-size: 1.875em;
    line-height: 1em;
    letter-spacing: -.08em;
    text-align: center;
}

p {
    text-align: center;
    margin: 0 365px 2em 0;
    padding: 0 6em;
}

接下来,通过将问题的不透明度降低到 0.2,使问题变得明显不再是交互式的。从按钮上移除活动和悬停状态,以确保它看起来不可点击。将#questions.closed规则添加到main.css : 中“问题视图”部分的末尾

#questions.closed { opacity: .4; }

#questions.closed .vote { opacity: .2; }

#questions.closed .vote input:active,
#questions.closed .vote input:hover,
#questions.closed .vote input:focus {
    background-position: left top;
    cursor: default;
}

重新加载您的浏览器;您会看到问题逐渐消失,“投票”按钮显示为禁用状态(图 7-24 )。

9781430246206_Fig07-24.jpg

图 7-24。已完成的封闭房间视图

媒体的询问呢?

因为您将更改保持得如此简单,所以不需要对媒体查询进行更新;它开箱后就能正常工作。

为演示者开发会议室视图

转移到后端功能之前的最后一步是为 presenter 视图创建标记。除了以下三点之外,该视图与与会者视图完全相同:

  • 没有“提出问题”的形式
  • 有一个“回答”表单和按钮,而不是“投票”按钮
  • 在房间信息下面有一个表单,有一个到房间的链接和一个关闭表单的按钮

返工现有标记

创建一个名为presenter.html的新文件,并将attendee-active.html的内容复制到其中。

接下来,删除问题上方的“提问”表单标记,并删除每个问题的“投票”表单,代之以“回答”表单和按钮。在<header>标签中添加一个新表单,其中包含房间信息,该信息具有一个禁用的文本输入,其值为房间的统一资源指示器(URI ),并添加一个提交按钮,其副本为“Close This Room ”,这将允许演示者结束会话。

总而言之,变化很少。唯一的区别是在<section>元素中:

<section>

    <header>
        <h2>Realtime Web Apps &amp; the Mobile Internet</h2>
        <p>
            Presented by Jason Lengstorf
            (<a href="mailto:jason@lengstorf.com" tabindex="100">email</a>)
        </p>

        <form id="close-this-room">
            <label>
                Link to your room.
                <input type="text" name="room-url"
                       value="[`realtime.local/room/1234`](http://realtime.local/room/1234)"
                       disabled />
            </label>
            <input type="submit" value="Close This Room" />
        </form><!--/#close-this-room-->

    </header>

    <ul id="questions" class="presenter">

        <li id="question-1"
            data-count="27">

            <form class="answer">
                <input type="submit" value="Answer this question." />
            </form>

            <p>
                What is the best way to implement realtime features today?
            </p>

        </li>

        <li id="question-2"
            data-count="14">

            <form class="answer">
                <input type="submit" value="Answer this question." />
            </form>

            <p>
                Does this work on browsers that don't support the
                WebSockets API?
            </p>

        </li>

    </ul><!--/#questions-->

</section>

在浏览器中查看此文件;它需要一点调整,但已经很接近了(见图 7-25 )。

9781430246206_Fig07-25.jpg

图 7-25。无样式演示者视图

更新 CSS

正如你在图 7-25 中看到的,唯一需要调整的是“回答”按钮和侧边栏中的表单。“回答”按钮使用 sprite,类似于“投票”按钮,浮动在问题的右侧。侧边栏中的表单只需要稍微窄一点,以适应可用的空间。

更新现有的#questions .vote#questions .vote input[type=submit]规则,使其也适用于演示者视图,并在main.css : 中的“问题视图”部分的底部添加一些特定的回答和结束规则

/* Updated rules */

#questions .vote,#questions .answer {
    position: relative;
    display: block;
    width: 76px;
    height: 76px;
    margin: 0 0 0 2em;
    padding: 0;
    overflow: hidden;
}

#questions .vote input[type=submit],
#questions .answer input[type=submit] {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
    cursor: pointer;
    text-indent: -9999px;
    background: url(../img/sprite.jpg) left top no-repeat;
    -webkit-box-shadow: none;
            box-shadow: none;
}

/* new rules */#questions .answer {
    float: right;
    width: 78px;
    height: 88px;
    margin: 0;
}

#questions .answer input[type=submit] { background-position: right top; }

#questions .answer input:active,
#questions .answer input:hover,
#questions .answer input:focus {
    background-position: right bottom;
}

#close-this-room { width: 340px; margin: 2em 0 0; }

#close-this-room label input { width: 305px; }

重新加载您的浏览器以查看正确样式的演示者视图(参见图 7-26 )。

9781430246206_Fig07-26.jpg

图 7-26。带有“回答”按钮活动状态的风格化演示者视图

更新媒体查询

presenter 视图的媒体查询与 attendee 视图的类似,只是不是将投票计数移动到左侧按钮的上方,而是移动到右侧计数的下方。

除此之外,“关闭这个房间”表单被重排以适应平板电脑和手持设备上的单列布局。

更新现有规则并将新规则添加到main.css的“媒体查询”部分,如下所示:

@media screen and (max-width: 960px)
{

    section header,#close-this-room {
        float: none;
        width: 680px;
        margin: 0 auto 1.5em;
        overflow: hidden;
    }

    #close-this-room { margin: 1em auto 0; }

    #close-this-room label { width: 59%; float: left;}

    #close-this-room label input { width: 88%; margin: 0; }

    #close-this-room input { float: left; margin: .6em 0 0; }

}

@media screen and (max-width: 768px)
{

    form,form#attending,form#ask-a-question,#close-this-room {
        float: none;
        width: 90%;
        margin: 0 auto 3em;
    }

    #ask-a-question label,#close-this-room label { width: 270px; }

    #ask-a-question input,#close-this-room input {
        float: none;
        margin: 0 auto;
    }

    /* New close rules */
    #close-this-room { margin: 1em auto 0; }

    #questions.presenter li:before {
        top: auto;
        right: 24px;
        bottom: 6px;
        left: auto;
        z-index: 10;
    }

}

在浏览器中保存并重新加载页面。你现在有了一个响应的演示者视图(见图 7-27 )。

9781430246206_Fig07-27.jpg

图 7-27。演示者视图,包括平板电脑和手持设备布局

摘要

此时,您已经有了应用的 HTML 和 CSS 模板,并且您已经准备好开始使用 PHP 和 MySQL 开发后端。

仍然有一些东西需要 CSS 样式,比如点击后的“投票”按钮,或者回答后的问题,但是你将在下一章开发该功能时添加这些样式。

在下一章中,您将构建服务器端脚本和数据库来使应用实际工作。

1

2

3

4`*

八、构建后端:第一部分

到目前为止,在 app 开发过程中,大部分的努力都是针对网站的前端。在本章中,您将构建驱动前端的后端脚本。

滚动你自己的简单 MVC 框架

在构建应用之前,你的首要任务是为它构建一个框架。正如我们之前所确定的,模型-视图控制器(MVC)框架将是这个特定构建中的最佳选择。

在本节中,您将从头开始构建一个 MVC 框架。这是一项复杂的任务,所以我们分两章来完成。在第一章中,你将建立主页的框架和它背后的一些逻辑,在第九章中,我们将填充 MVC 结构并完成应用。

确定目录结构

一个好的应用从适当的组织开始。在 web 根文件夹中,创建两个文件夹并添加子文件夹,如下所示:

  • assets

  • images

  • scripts

  • styles

  • system

  • config

  • controllers

  • core

  • inc

  • lib

  • models

  • views

在我们构建应用时,我们将介绍这些文件夹的用途;现在,请放心,这个应用将组织良好。

为所有请求设置路由

您的框架需要的第一件事是一个将请求路由到适当位置的脚本。该脚本将初始化任何必要的配置变量,加载任何额外的必需脚本,并确定用户通过适当的控制器发送请求的意图(通过 URL)。

设置配置变量

因为这个应用的每个安装可能会有不同的配置,你将建立一个配置文件。在/system/config中,创建一个名为config.inc.php的新文件,并插入以下内容:

<?php

/**
 * A sample configuration file
 *
 * The variables below need to be filled out with environment specific data.
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */

// Set up an array for constants
$_C = array();

//-----------------------------------------------------------------------------
// Converts the constants array into actual constants
//-----------------------------------------------------------------------------

foreach ($_C as $constant=>$value) {
    define($constant, $value);
}

到目前为止,这实际上并没有创建任何配置变量,但是它建立了一个结构来实现这一点。所有配置变量都将被添加到$_C数组中,该数组通过脚本底部的一个foreach循环运行,将每个变量定义为一个常量。

image 注意配置变量被定义为常量是因为 1)它们是不可变的,这意味着它们在执行过程中不能被更改;2)它们需要在全局范围内,以便对函数和类可用。

结构就绪后,开始添加配置变量。在本章中,我们需要存储以下特定于应用的数据:

  • 应用的时区
  • 数据库配置信息
  • 是否显示调试信息

config.inc.php中,添加粗体显示的代码,将配置变量声明为常量:

<?php

/**
 * A sample configuration file
 *
 * The variables below need to be filled out with environment specific data.
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */

// Set up an array for constants
$_C = array();

//-----------------------------------------------------------------------------
// General configuration options
//-----------------------------------------------------------------------------

$_C['APP_TIMEZONE'] = 'US/Pacific';

//-----------------------------------------------------------------------------
// Database credentials
//-----------------------------------------------------------------------------

$_C['DB_HOST'] = 'localhost';
$_C['DB_NAME'] = '';
$_C['DB_USER'] = '';
$_C['DB_PASS'] = '';

//-----------------------------------------------------------------------------
// Enable debug mode (strict error reporting)
//-----------------------------------------------------------------------------

$_C['DEBUG'] = TRUE;

//-----------------------------------------------------------------------------
// Converts the constants array into actual constants
//-----------------------------------------------------------------------------

foreach ($_C as $constant=>$value) {
    define($constant, $value);
}

为每个变量添加正确的值后,保存此文件;您可以稍后在编辑器中关闭它,因为在本章中您不需要再次编辑该文件。

image 注意不要忘记使用您的开发数据库凭证更新DB_HOSTDB_NAMEDB_USERDB_PASS值。如果没有它们,当您在本章后面构建数据库连接脚本时,应用将抛出一个致命错误。

正在初始化应用

应用的首要任务是设置环境变量和全局设置。这将让应用知道文件在哪里,如何连接到数据库,是否显示调试信息,以及其他步骤,使事情在引擎盖下顺利运行。

在 web 根文件夹中,创建一个名为index.php的新文件。在里面,首先设置应用的基础:

<?php

/**
 * The initialization script for the app
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */

//-----------------------------------------------------------------------------
// Initializes environment variables
//-----------------------------------------------------------------------------

// Server path to this app (i.e. /var/www/vhosts/realtime/httpdocs/realtime)
define('APP_PATH',   dirname(__FILE__));

// App folder, relative from web root (i.e. /realtime)
define('APP_FOLDER', dirname($_SERVER['SCRIPT_NAME']));

// URL path to the app (i.e.http://example.org/realtime/)
define(
    'APP_URI',
    remove_unwanted_slashes('http://' . $_SERVER['SERVER_NAME'] . APP_FOLDER . '/')
);

// Server path to the system folder (for includes)
define('SYS_PATH',   APP_PATH . '/system');

APP_PATH是一个常量,它将存储应用在服务器上的绝对路径。这是用于 PHP 包含。

另一方面,APP_FOLDER存储来自 web 根的相对路径。它将用于相对链接或 CSS 包含和资产路径。为了避免从子目录运行应用的问题,包含了一个尾随斜线。由于调用了remove_unwanted_slashes(),我们可以轻松地做到这一点。

APP_URI是 app 的实际 URI。例如,如果应用位于www.example.org的网络根目录下,APP_URI将包含http://www.example.org/;,如果应用位于名为 realtime 的子目录中,APP_URI将存储http://www.example.org/realtime/

image APP_URI部分是用一个叫做remove_unwanted_slashes()的函数确定的,这个函数还没有定义。这将在下一节中添加。

最后,SYS_PATH包含系统文件的路径(这是 MVC 框架的大部分)。

在应用常量下面,您现在可以设置会话,包括配置变量,为应用设置适当的错误报告级别,以及设置应用时区。将粗体显示的代码添加到index.php :

<?php

/**
 * The initialization script for the app
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */

//-----------------------------------------------------------------------------
// Initializes environment variables
//-----------------------------------------------------------------------------

// Server path to this app (i.e. /var/www/vhosts/realtime/httpdocs/realtime)
define('APP_PATH',   dirname(__FILE__));

// App folder, relative from web root (i.e. /realtime)
define('APP_FOLDER', dirname($_SERVER['SCRIPT_NAME']));

// URL path to the app (i.e.http://example.org/realtime)
define(
    'APP_URI',
    remove_unwanted_slashes('http://' . $_SERVER['SERVER_NAME'] . APP_FOLDER)
);

// Server path to the system folder (for includes)
define('SYS_PATH',   APP_PATH . '/system');

// Relative path to the form processing script (i.e. /realtime/process.php)
define('FORM_ACTION', remove_unwanted_slashes(APP_FOLDER . '/process.php'));

//-----------------------------------------------------------------------------
// Initializes the app
//-----------------------------------------------------------------------------

// Starts the session
if (!isset($_SESSION)) {
    session_start();
}

// Loads the configuration variables
require_once SYS_PATH . '/config/config.inc.php';

// Turns on error reporting if in debug mode
if (DEBUG===TRUE) {
    ini_set('display_errors', 1);
    error_reporting(E_ALL^E_STRICT);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

// Sets the timezone to avoid a notice
date_default_timezone_set(APP_TIMEZONE);

因为这个应用将利用会话在页面加载之间传递数据,所以如果没有设置$_SESSION超级全局,脚本将调用session_start()

然后,在加载配置变量之后,脚本检查DEBUG的值,并且——如果它被设置为TRUE,在开发期间应该总是这样——打开严格的错误报告;否则,会抑制错误。

最后,因为 PHP 会抛出一个没有它的通知,所以使用APP_TIMEZONE变量来设置时区。

设置实用功能

为了避免混淆路由的逻辑,复杂的操作应该封装在函数中。幸运的是,本节没有太多复杂的操作,所以我们只需要创建四个实用函数:

  • 解析 URI 并将其各部分作为数组返回的函数
  • 使用 URI 部件确定控制器名称的函数
  • 一种功能,用于防止 URI 中除其协议以外的任何部分出现双斜线
  • 一个自动加载器,将检查我们的应用中的类,并包括它们(或提供一个有用的错误消息,如果请求的类不存在)

解析 URI

因为我们希望我们的应用有漂亮的 URIs,而不是笨拙的查询字符串,我们需要一种方法来确定 URI 的哪些部分是用于配置的,哪些只是 URI 的一部分。

image 注意因为这个应用可能不总是安装在 URI 的根目录下,所以解析脚本需要将 URI 与应用的位置进行比较,并只返回URI 中不引用应用在服务器上的位置的部分。

*例如,如果应用安装在http://www.example.org/,房间 ID 1234 的 URI 将是http://www.example.org/room/1234。然而,如果应用安装在名为realtime的子目录中,则房间 ID 1234 的 URI 将是http://www.example.org/realtime/room/1234

在这两种情况下,我们只希望通过parse_uri()函数返回“房间”和“1234”。

然后,URI 中与位置无关的部分将被以正斜杠分开,并存储为供应用使用的数组,应用将使用它们来确定要显示的视图(以及稍后将介绍的其他一些内容)。

index.php的底部,使用粗体显示的代码添加 URI 解析函数:

//-----------------------------------------------------------------------------
// Initializes the app
//-----------------------------------------------------------------------------

// Starts the session
if (!isset($_SESSION)) {
    session_start();
}

// Loads the configuration variables
require_once SYS_PATH . '/config/config.inc.php';

// Turns on error reporting if in debug mode
if (DEBUG===TRUE) {
    ini_set('display_errors', 1);
    error_reporting(E_ALL^E_STRICT);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

// Sets the timezone to avoid a notice
date_default_timezone_set(APP_TIMEZONE);

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

/**
 * Breaks the URI into an array at the slashes
 *
 * @return array  The broken up URI
 */
function parse_uri(  )
{
    // Removes any subfolders in which the app is installed
    $real_uri = preg_replace(
            '∼^'.APP_FOLDER.'∼',
            '',
            $_SERVER['REQUEST_URI'],
            1
        );

    $uri_array = explode('/', $real_uri);

    // If the first element is empty, get rid of it
    if (empty($uri_array[0])) {
        array_shift($uri_array);
    }

    // If the last element is empty, get rid of it
    if (empty($uri_array[count($uri_array)-1])) {
        array_pop($uri_array);
    }

    return $uri_array;
}

parse_uri()函数首先使用preg_replace()从请求的 URI 中删除APP_FOLDER,只留下告诉应用用户请求什么的位。然后,该函数使用explode()在正斜杠处分割 URI,然后在返回数组之前检查数组开头和结尾的空元素。

获取控制器名称

接下来,您需要找出合适的控制器名称来加载给定的 URI 部件。为此,将以下粗体代码添加到index.php:

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

/**
 * Breaks the URI into an array at the slashes
 *
 * @return array  The broken up URI
 */
function parse_uri(  )
{
    // Removes any subfolders in which the app is installed
    $real_uri = preg_replace(
            '∼^'.APP_FOLDER.'∼',
            '',
            $_SERVER['REQUEST_URI'],
            1
        );

    $uri_array = explode('/', $real_uri);

    // If the first element is empty, get rid of it
    if (empty($uri_array[0])) {
        array_shift($uri_array);
    }

    // If the last element is empty, get rid of it
    if (empty($uri_array[count($uri_array)-1])) {
        array_pop($uri_array);
    }

    return $uri_array;
}

/**
 * Determines the controller name using the first element of the URI array
 *
 * @param $uri_array array  The broken up URI
 * @return string           The controller classname
 */
function get_controller_classname( &$uri_array )
{
    $controller = array_shift($uri_array);
    return ucfirst($controller);
}

这个函数很简单:通过引用将 URI 传递给函数,将第一个元素加载到变量$controller中,然后将第一个字母大写并返回该值。

image 注意使用&符号(&)通过引用传递变量意味着在函数内部执行的操作不仅会影响传递给它的数据,还会影响调用该函数的范围。

这意味着http://example.com/room/1234/的 URI 将被解析为具有以下结构的数组:

array(2) {
  [0]=>
  string(4) "room"
  [1]=>
  string(4) "1234"
}

数组的第一个元素“room”将被隔离、大写,然后返回,给我们这个get_controller_classname()函数的返回值:

Room

image 我们将在本章的稍后部分讨论如何使用这个返回值。

避免不必要的斜线

每当你处理 URIs 时,URI 部分总有可能有前导或尾随斜线。当这些部分结合在一起时,就会产生问题。

例如,站点的 URI 可能存储在一个变量中,如下所示:

$site_uri = 'http://www.example.org/';

如果一个链接被设置为相对于 web 根目录,它可能会被声明如下:

$services_link = '/services/';

现在假设您的应用需要提供一个可以从任何网页访问的服务页面链接。您的第一反应可能是:

$services_uri = $site_uri . $services_link;

但是,该变量将具有以下值:

http://www.example.org//services/

第二个双斜线是一个问题,当你的 URI 组件从不同的位置被抓取时($_SERVER超全局,特定于应用的配置,等等。)不太可能会出现不想要的双斜线情况。

因此,值得编写一个函数来检测并删除给定 URI 中任何不需要的双斜线。但是,因为协议——意思是“http://”部分——有两个斜线,所以需要特别注意不要破坏 URI。

为此,将以下粗体代码添加到 index.php 中:

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

/**
 * Breaks the URI into an array at the slashes
 *
 * @return array  The broken up URI
 */
function parse_uri(  )
{
    // Removes any subfolders in which the app is installed
    $real_uri = preg_replace(
            '∼^'.APP_FOLDER.'∼',
            '',
            $_SERVER['REQUEST_URI'],
            1
        );

    $uri_array = explode('/', $real_uri);

    // If the first element is empty, get rid of it
    if (empty($uri_array[0])) {
        array_shift($uri_array);
    }

    // If the last element is empty, get rid of it
    if (empty($uri_array[count($uri_array)-1])) {
        array_pop($uri_array);
    }

    return $uri_array;
}

/**
 * Determines the controller name using the first element of the URI array
 *
 * @param $uri_array array  The broken up URI
 * @return string           The controller classname
 */
function get_controller_classname( &$uri_array )
{
    $controller = array_shift($uri_array);
    return ucfirst($controller);
}

/**
 * Removes unwanted double slashes (except in the protocol)
 *
 * @param $dirty_path string    The path to check for unwanted slashes
 * @return string               The cleaned path
 */
function remove_unwanted_slashes( $dirty_path )
{
    return preg_replace('∼(?<!:)//∼', '/', $dirty_path);
}

使用preg_replace(),该函数检查前面没有冒号(:)的双斜线(//)的出现,并用单斜线(/)替换它们。

因为正则表达式看 1 会有点毛,所以我们把这个一点一点分解一下:

  • —开始分隔符;这只是告诉函数一个正则表达式模式开始了

  • (?<!:)—所谓的“负面回顾”,有三个主要组成部分:

  • 括号—定义后视

  • ?<!—实际的 look back,字面意思是告诉正则表达式,“在匹配的字符之前查看该字符”

  • :—我们不想匹配的表情或性格;在这种情况下,它是两个斜杠前面的冒号,这表明它是协议,不应该被替换

  • //—要查找的字符;在这种情况下,双斜线

  • —结束分隔符;这告诉函数正则表达式模式结束了

继续前面的 URI 例子,双斜线问题是通过运行组合的 URI 部分到remove_unwanted_slashes()来解决的:

$services_uri = remove_unwanted_slashes($site_uri . $services_link);

这在$services_uri中存储了一个适当的 URI:

http://www.example.org/services/

自动加载类

最后,为了避免加载大量未使用的 PHP 类,您需要一个自动加载器,只在需要访问文件时才抓取它们。

这是通过创建一个函数在所有存储类的地方进行搜索来实现的;然后使用spl_autoload_register()将该功能注册为自动加载器。

image 注意__autoload()函数曾经是标准的,但是 PHP 现在推荐使用spl_autoload_register(),因为它有更好的灵活性和性能。

首先,让我们参考应用的文件夹结构,并确定在构建应用时可能保存类文件的所有位置。

该函数将加载三个类类型:

  • 控制器将存储在system/controllers/
  • 模型存储在system/models/
  • 核心文件将存储在system/core/中(我们将在后面详细讨论这些文件)

有了可能位置的列表,该函数将遍历每个位置,并查看该类是否存在;如果是,它将加载该类并返回TRUE;如果没有,它抛出一个Exception,声明该类不存在。

添加以下粗体代码来实现这一点。别忘了调用 app 初始化块中的 spl_autoload_register()

<?php

/**
 * The initialization script for the app
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */

//-----------------------------------------------------------------------------
// Initializes environment variables
//-----------------------------------------------------------------------------

// Server path to this app (i.e. /var/www/vhosts/realtime/httpdocs/realtime)
define('APP_PATH',   dirname(__FILE__));

// App folder, relative from web root (i.e. /realtime)
define('APP_FOLDER', dirname($_SERVER['SCRIPT_NAME']));

// URI path to the app (i.e.http://example.org/realtime)
define(
    'APP_URI',
    remove_unwanted_slashes('http://' . $_SERVER['SERVER_NAME'] . APP_FOLDER)
);

// Server path to the system folder (for includes)
define('SYS_PATH',   APP_PATH . '/system');

// Relative path to the form processing script (i.e. /realtime/process.php)
define('FORM_ACTION', remove_unwanted_slashes(APP_FOLDER . '/process.php'));

//-----------------------------------------------------------------------------
// Initializes the app
//-----------------------------------------------------------------------------

// Starts the session
if (!isset($_SESSION)) {
    session_start();
}

// Loads the configuration variables
require_once SYS_PATH . '/config/config.inc.php';

// Turns on error reporting if in debug mode
if (DEBUG===TRUE) {
    ini_set('display_errors', 1);
    error_reporting(E_ALL^E_STRICT);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

// Sets the timezone to avoid a notice
date_default_timezone_set(APP_TIMEZONE);

// Registers class_loader() as the autoload function
spl_autoload_register('class_autoloader');

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

/**
 * Breaks the URI into an array at the slashes
 *
 * @return array  The broken up URI
 */
function parse_uri(  )
{
    // Removes any subfolders in which the app is installed
    $real_uri = preg_replace(
            '∼^'.APP_FOLDER.'∼',
            '',
            $_SERVER['REQUEST_URI'],
            1
        );

    $uri_array = explode('/', $real_uri);

    // If the first element is empty, get rid of it
    if (empty($uri_array[0])) {
        array_shift($uri_array);
    }

    // If the last element is empty, get rid of it
    if (empty($uri_array[count($uri_array)-1])) {
        array_pop($uri_array);
    }

    return $uri_array;
}

/**
 * Determines the controller name using the first element of the URI array
 *
 * @param $uri_array array  The broken up URI
 * @return string           The controller classname
 */
function get_controller_classname( &$uri_array )
{
    $controller = array_shift($uri_array);
    return ucfirst($controller);
}

/**
 * Removes unwanted slashes (except in the protocol)
 *
 * @param $dirty_path string    The path to check for unwanted slashes
 * @return string               The cleaned path
 */
function remove_unwanted_slashes( $dirty_path )
{
    return preg_replace('∼(?<!:)//∼', '/', $dirty_path);
}

/**
 * Autoloads classes as they are instantiated
 *
 * @param $class_name string    The name of the class to be loaded
 * @return bool                 Returns TRUE on success (Exception on failure)
 */
function class_autoloader( $class_name )
{
    $fname = strtolower($class_name);

    // Defines all of the valid places a class file could be stored
    $possible_locations = array(
        SYS_PATH . '/models/class.' . $fname . '.inc.php',
        SYS_PATH . '/controllers/class.' . $fname . '.inc.php',
        SYS_PATH . '/core/class.' . $fname . '.inc.php',
    );

    // Loops through the location array and checks for a file to load
    foreach ($possible_locations as $loc) {
        if (file_exists($loc)) {
            require_once $loc;
            return TRUE;
        }
    }

    // Fails because a valid class wasn't found
    throw new Exception("Class $class_name wasn't found.");
}

完成路由

创建了实用程序函数并准备好自动加载器来加载所请求的控制器后,路由脚本的最后一步是实际处理请求控制器的 URI 组件,并最终将正确的视图发送给用户。

加载控制器

index.php中,在初始化块和函数声明之间添加以下代码:

// Registers class_loader() as the autoload function
spl_autoload_register('class_autoloader');

//-----------------------------------------------------------------------------
// Loads and processes view data
//-----------------------------------------------------------------------------

// Parses the URI
$uri_array  = parse_uri();
$class_name = get_controller_classname($uri_array);
$options    = $uri_array;

// Sets a default view if nothing is passed in the URI (i.e. on the home page)
if (empty($class_name)) {
    $class_name = 'Home';
}

// Tries to initialize the requested view, or else throws a 404 error
try {
    $controller = new $class_name($options);
} catch (Exception $e) {
    $options[1] = $e->getMessage();
    $controller = new Error($options);
}

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

使用效用函数,URI 被分解并存储在$uri_array中。然后它被传递给get_controller_classname(),?? 将控制器的类名存储在$class_name中。剩余的 URI 组件储存在$options中以备后用。

接下来,检查$class_name以确保它不为空;如果是,则提供默认的类名“Home”。

最后,使用一个try...catch块,实例化一个请求类型的新控制器对象,将$options作为参数传递给构造函数。如果出现任何问题,就会创建一个新的Error对象来显示错误消息。

image 注意你将在本章的后面构建Error类。

输出视图

加载控制器后,除了输出标记之外,没有什么要做的了。在index.php中,添加以下粗体代码:

//-----------------------------------------------------------------------------
// Loads and processes view data
//-----------------------------------------------------------------------------

// Parses the URI
$uri_array  = parse_uri();
$class_name = get_controller_classname($uri_array);
$options    = $uri_array;

// Sets a default view if nothing is passed in the URI (i.e. on the home page)
if (empty($class_name)) {
    $class_name = 'Home';
}

// Tries to initialize the requested view, or else throws a 404 error
try {
    $controller = new $class_name($options);
} catch (Exception $e) {
    $options[1] = $e->getMessage();
    $controller = new Error($options);
}

//-----------------------------------------------------------------------------
// Outputs the view
//-----------------------------------------------------------------------------

// Includes the header, requested view, and footer markup
require_once SYS_PATH . '/inc/header.inc.php';

$controller->output_view();

require_once SYS_PATH . '/inc/footer.inc.php';

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

页眉和页脚标记——您将在本章稍后创建——是简单的包含,夹在它们之间的是对控制器对象的output_view()方法的调用,该方法为请求的视图输出格式化的标记。

添加 URI 重写本

让路由运行的最后一步是添加.htaccess文件,该文件将通过路由引导所有请求(除非直接请求文件或子目录)。

在应用的根目录下,创建一个名为.htaccess的新文件,并插入以下代码:

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php [L]
</IfModule>

您可能需要更新RewriteBase的值,以匹配应用的路径。一些服务器设置将在没有设置RewriteBase的情况下运行,因此如果您遇到错误,请先尝试删除它,然后将其设置为您的应用的路径。

image 注意如果你是一名 WordPress 开发者,你可能会认出这段代码。这和 WordPress 网站上使用的重写规则是一样的。

设置核心类

因为应用中使用的每个控制器、视图和模型都有基线功能,所以在构建它们之前,你需要创建抽象类来容纳公共方法,并声明需要包含在扩展它们的所有类中的任何方法和/或属性存根。

什么是抽象类?

定义为 抽象的类不能被实例化,任何包含至少一个抽象方法的类也必须是抽象的。定义为抽象的方法只是声明方法的签名,它们不能定义实现。

从抽象类继承时,所有在父类声明中标记为抽象的方法必须由子类定义。]

—摘自 PHP 手册关于类抽象的词条 2

这意味着,抽象类允许开发人员创建充当其他类的模板的类,同时还提供公共功能。因为抽象类不能被直接实例化,所以可以创建方法“存根”,这要求子类声明那些方法并为它们提供功能。

举个简单的例子,一个抽象类可以定义一个人。所有人都睡觉和喝酒,所以抽象类应该定义那些方法。因为睡眠对所有人来说几乎都是一样的,这可以在课堂上定义。然而,不是所有的人都喝同样的东西;该动作应该被定义为要在子类中声明的存根。

下面是这个简单的例子在真实代码中的样子:

abstract class Person
{
    public $rest   = 0,
           $drinks = array();

    public function sleep(  )
    {
        ++$rest;
    }

    abstract public function drink(  );
}

class Jason extends Person
{
    private $_wishes = array(
        'a little bit taller',
        'a baller',
        'a girl who looks good',
        'a rabbit in a hat',
        'a bat',
        'a \'64 Impala',
    );

    public function drink(  )
    {
        $this->drinks[] = 'coffee';
    }

    public function wish(  )
    {
        $wish_index = mt_rand(0, count($this->_wishes)-1);
        return $this->_wishes[$wish_index];
    }
}

class Phil extends Person
{
    public function drink(  )
    {
        $this->drinks[] = 'tea';
    }
}

Person类设置了通用的属性,以及一个所有扩展该类的类(人)将共享的sleep()方法。

然后它定义了一个抽象方法drink()。因为所有的人都喝,但不是所有的人都喝同样的饮料,这个方法需要被要求,但没有被定义;这就是抽象类的强大之处。

现在,当定义了JasonPhil类时,基础就已经奠定了——不需要额外的代码来允许休眠——所以完成这些类的代码非常简单。

同样值得注意的是,扩展抽象类的类不受所提供的方法和方法存根的限制;可以根据需要声明附加的属性和方法。例如,Jason类包含一个私有属性——$_wishes——和一个名为wish()的附加方法。 3

创建抽象控制器类

Controller类非常简单:它需要检查所需的数据是否被传入,并为生成页面标题和解析视图定义方法存根。

创建一个名为class.controller.inc.php的新文件,并保存在system/core/子目录中。在内部,粘贴以下代码:

<?php

/**
 * An abstract class that lays the groundwork for all controllers
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
abstract class Controller
{

    public $actions = array(),
           $model;

    protected static $nonce = NULL;

    /**
     * Initializes the view
     *
     * @param $options array    Options for the view
     * @return void
     */
    public function __construct( $options )
    {
        if (!is_array($options)) {
            throw new Exception("No options were supplied for the room.");
        }
    }

    /**
     * Generates a nonce that helps prevent XSS and duplicate submissions
     *
     * @return string   The generated nonce
     */
    protected function generate_nonce(  )
    {
        // TODO: Add the actual nonce generation script
        return "tempnonce";
    }

    /**
     * Performs basic input sanitization on a given string
     *
     * @param $dirty    string  The string to be sanitized
     * @return          string  The sanitized string
     */
    protected function sanitize( $dirty )
    {
        return htmlentities(strip_tags($dirty), ENT_QUOTES);
    }

    /**
     * Sets the title for the view
     *
     * @return string   The text to be used in the <title> tag
     */
    abstract public function get_title(  );

    /**
     * Loads and outputs the view's markup
     *
     * @return void
     */
    abstract public function output_view(  );

}

__construct()方法检查中的选项数组(将是由index.php中的parse_uri()提取的数组),如果没有提供选项,则抛出Exception

目前,generate_nonce()方法只是返回一个临时字符串。在本章后面的“处理表单提交”一节中,您将再次用到这个方法。

sanitize()方法做一些非常基本的输入净化,这应该总是在用户提供的数据上执行。

在本章的后面,你将构建一个扩展这个类的控制器,但是首先你需要一个处理视图的方法(否则output_view()方法会崩溃)。

创建视图类

为了让显示输出,你需要一个View类。本质上,这个类将加载由Controller类请求的视图并返回它们。但是,因为显示的数据会因房间而异,View类也需要将数据插入视图;这将需要一个简单的设置器实现,我们将在稍后讨论。

system/core/子目录中,创建一个名为class.view.inc.php的新文件。在内部,添加以下代码:

<?php

/**
 * Parses template files with loaded data to output HTML markup
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class View
{

    protected $view,
              $vars = array();

    /**
     * Initializes the view
     *
     * @param $view array   The view slug
     * @return void
     */
    public function __construct( $view=NULL ) {
        if (!$view) {
            throw new Exception("No view slug was supplied.");
        }

        $this->view = $view;
    }

    /**
     * Stores data for the view into an array
     *
     * @param $key string   The variable name
     * @param $var string   The variable value
     * @return void
     */
    public function __set( $key, $var ) {
        $this->vars[$key] = $var;
    }

    /**
     * Loads and parses the selected template using the provided data
     *
     * @param $print boolean    Whether the markup should be output directly
     * @return mixed            A string of markup if $print is TRUE or void
     */
    public function render( $print=TRUE ) {
        // Converts the array of view variables to individual variables
        extract($this->vars);

        // Checks to make sure the requested view exists
        $view_filepath = SYS_PATH . '/views/' . $this->view . '.inc.php';
        if (!file_exists($view_filepath)) {
            throw new Exception("That view file doesn't exist.");
        }

        // Turns on output buffering if markup should be returned, not printed
        if (!$print) {
            ob_start();
        }

        require $view_filepath;

        // Returns the markup if requested
        if (!$print) {
            return ob_get_clean();
        }
    }

}

image 注意你可能已经注意到View类不是抽象的。这是因为它的功能不需要在这个应用中扩展。

从顶部开始,这个类定义了两个属性:$view,存储要加载的视图的名称;和$vars,这是一个特定于视图的键值对数组,用于定制输出。

__construct()方法检查视图段或标识视图的字符串,并将其存储在对象中以备后用,如果没有提供,则抛出Exception

__set()方法是另一个神奇的方法(我们将在下一节详细讨论),类似于__construct(),它允许将数据作为属性存储在对象中,即使属性没有明确定义。这为在视图中添加输出变量创建了一个快捷方式。

最后,render()函数使用函数extract()将所有定制属性存储到变量中,检查有效的视图文件,并根据$print标志打印或返回视图的标记。

为什么 SETTERS 有用

像 PHP 中所有其他神奇的方法一样,__set()方法实际上并没有那么神奇;它只是提供了一条捷径来做一些原本会很麻烦的事情。

例如,我们的各种视图将不会共享相同的输出变量:一个房间将有一个标题和演讲者,而问题视图将有问题文本和投票数。

虽然您可以在各自的视图中显式声明每个属性,但是如果将来添加了额外的属性,就会增加维护的麻烦。

或者,您可以使用一个专用属性来保存每个视图的自定义变量数组:

<?php

class RWA_Example
{
    public $vars = array();
}

$test = new RWA_Example;

// Sets custom variables
$test->vars['foo'] = 'bar';
$test->vars['bat'] = 'baz';

// Gets custom variables
echo $test->vars['foo'];
echo $test->vars['bat'];

这是一个完全可以接受的解决方案,但是输入起来有点笨拙。

使用 magic setter 方法通过提供一种快捷方式简化了这个过程:简单地设置属性,就像它们被显式声明一样,然后使用__set()将它们放入一个数组中。

为了检索定制变量,还有另一个神奇的方法叫做__get()

下面是上一个使用 getters 和 setters 的例子:

<?php

class RWA_Example
{
    protected $magic = array();

    public function __set( $key, $val )
    {
        $this->magic[$key] = $val;
    }

    public function __get( $key )
    {
        return $this->magic[$key];
    }
}

$test = new RWA_Example;

// Sets custom properties
$test->foo = 'bar';
$test->bat = 'baz';

// Gets custom properties
echo $test->foo;
echo $test->bat;

这极大地提高了代码的可读性,降低了输入错误的风险,从而为动态地向对象添加属性创建了一个有效的快捷方式。

创建抽象模型类

需要的最后一个核心类是Model类,这是三个类中最简单的一个。对于这个应用,所有的Model类需要做的就是创建一个数据库连接。

system/core/子目录中,创建一个名为class.model.inc.php的新文件,并插入以下代码:

<?php

/**
 * Creates a set of generic database interaction methods
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
abstract class Model
{

    public static $db;

    /**
     * Creates a PDO connection to MySQL
     *
     * @return boolean  Returns TRUE on success (dies on failure)
     */
    public function __construct(  ) {
        $dsn = 'mysql:dbname=' . DB_NAME . ';host=' . DB_HOST;
        try {
            self::$db = new PDO($dsn, DB_USER, DB_PASS);
        } catch (PDOExeption $e) {
            die("Couldn't connect to the database.");
        }

        return TRUE;
    }

}

__construct()方法试图使用存储在system/config/config.inc.php中的值创建一个新的 MySQL 连接,如果连接失败,将抛出一个Exception

image 注意我们使用 PHP 数据对象(PDO) 4 进行数据库访问,因为它提供了一个简单的接口,如果使用得当,几乎不可能使用 SQL 注入。

添加页眉和页脚标记

在实际构建应用页面之前的最后一步是将页眉和页脚标记添加到应用中以供通用。

从最简单的文件开始,在system/inc/中创建一个名为footer.inc.php的新文件,并插入您在第七章中构建的页脚标记:

<footer>
    <ul>
        <li class="copyright">
            &copy; 2013 Jason Lengstorf &amp; Phil Leggetter
        </li><!--/.copyright-->
        <li>
            Part of <em>Realtime Web Apps: With HTML5 WebSocket, PHP,
                and jQuery</em>.
        </li>
        <li>
            <a href="http://amzn.to/XKcBbG">Get the Book</a> |
            <a href="http://cptr.me/UkMSmn">SourceCode (on GitHub)</a>
        </li>
    </ul>
</footer>

</body>

</html>

页脚标记中没有什么值得注意的;然而,头部引入了我们第一个需要可变数据的标记。让我们从创建标记开始,然后在下一节处理变量的设置。

创建一个名为header.inc.php的新文件,并保存在system/inc/中,其中包含以下标记(变量以粗体显示):

<!doctype html>
<html lang="en">

<head>

<meta charset="utf-8" />

<title><?php echo $title; ?></title>

<!-- Fonts via fonts.com -->
<script type="text/javascript"
        src="http://fast.fonts.com/jsapi/6a912a6e-163c-4c8b-afe0-e3d22ffab02e.js"></script>

<!-- Main site styles -->
<link rel="stylesheet" href="<?php echo $css_path; ?>" />

</head>

<body>

<header>
    <h1>Realtime Q&amp;A</h1>
    <p class="tagline">
        A live feedback system for classes, presentations, and conferences.
    </p><!--/.tagline-->
</header>

该标记设置 HTML5 文档类型和基本元数据,使用变量设置页面标题,然后加载字体样式表(来自 Fonts.com)。站点样式表的位置存储在一个变量中,因为它的位置需要相对于应用的安装来确定,以避免破坏 URI。

因为应用在每个页面的顶部共享通用的标题标记,所以这也包括在内。

为标题设置变量

为了设置标题的变量,再次打开index.php并添加以下代码,以粗体显示:

//-----------------------------------------------------------------------------
// Outputs the view
//-----------------------------------------------------------------------------

// Loads the <title> tag value for the header markup
$title = $controller->get_title();

// Sets the path to the app stylesheet for the header markup
$dirty_path = APP_URI . '/assets/styles/main.css';
$css_path = remove_unwanted_slashes($dirty_path);

// Includes the header, requested view, and footer markup
require_once SYS_PATH . '/inc/header.inc.php';

$controller->output_view();

require_once SYS_PATH . '/inc/footer.inc.php';

//-----------------------------------------------------------------------------
// Function declarations
//-----------------------------------------------------------------------------

使用控制器对象的get_title()方法设置第一个变量$title

接下来,使用APP_URI常量和样式表的路径生成样式表路径,在存储到$css_path用于输出之前,检查样式表的双斜线。

这个样式表路径变量是不是大材小用了?

乍一看,生成$css_path变量的步骤似乎是不必要的,URI 可以很容易地硬编码到标题标记中。毕竟档案一直在assets/styles/main.css吧?

因为我们使用 URI 重写,所以我们不能使用相对路径(即href="./assets/styles/main.css")。

使用绝对 URI,比如href="/assets/styles/main.css",只要应用安装在服务器的根目录下就可以了。

然而,如果应用安装在子目录中,绝对 URI 需要编辑,以包括子目录路径,我们不希望每次安装都需要编辑标题标记。

因此,将两行代码放在一起确定样式表的绝对路径是避免大麻烦的一件小事。

将样式表和资产复制到正确的目录中

现在头部标记已经就位并引用了样式表,您应该将第七章中的main.css复制到assets/styles/文件夹中。你也应该复制图像精灵到assets/img/

image 注意为了让事情向前发展,本章将不再提及或打印 CSS 标记和资产。别忘了把第七章的main.css抄过来,不然 app 跟后面的截图不匹配。

建立主页

路由创建完毕。核心类都写好了。通用标记已经准备就绪。剩下要做的就是创建应用的第一个实际页面。

首先从最简单的页面开始,让我们构建主页。

创建家庭控制器

首先,在system/controllers/中创建一个名为class.home.inc.php的新文件。在内部,添加以下代码:

<?php

/**
 * Generates output for the Home view
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Home extends Controller
{

    /**
     * Overrides the parent constructor to avoid an error
     *
     * @return bool TRUE
     */
    public function __construct(  )
    {
        return TRUE;
    }

    /**
     * Generates the title of the page
     *
     * @return string   The title of the page
     */
    public function get_title(  )
    {
        return 'Realtime Q&amp;A';
    }

    /**
     * Loads and outputs the view's markup
     *
     * @return void
     */
    public function output_view(  )
    {
        $view = new View('home');

        $view->render();
    }

}

正如我们在构建抽象的Controller类时讨论的那样,Home类扩展了Controller

首先—因为主页不接受任何参数—构造函数被覆盖以避免Exception

然后声明了get_title(),它简单地返回了在<title>标签中使用的应用的名称。

最后,output_view()方法创建了一个View类的新实例,并调用它的render()方法来输出标记。

接下来,让我们创建主页标记并为输出生成任何必要的变量。

创建主视图

主页的标记将保存在一个名为home.inc.php的文件中,该文件应该在system/views/中创建并保存。在里面,添加你在第七章中创建的 home 标记(需要由变量设置的部分已经加粗):

<section>

    <form id="attending" method="post"
          action=" <?php echo $join_action; ?>">
        <h2>Attending?</h2>
        <p>Join a room using its ID.</p>
        <label>
            What is the room's ID?
            <input type="text" name="room_id" />
        </label>
        <input type="submit" value="Join This Room" />
        <input type="hidden" name="nonce"
               value=" <?php echo $nonce; ?>" />
    </form><!--/#attending-->

    <form id="presenting" method="post"
          action=" <?php echo $create_action; ?>">
        <h2>Presenting?</h2>
        <p>Create a room to start your Q&amp;A session.</p>
        <label>
            Tell us your name (so attendees know who you are).
            <input type="text" name="presenter-name" />
        </label>
        <label>
            Tell us your email (so attendees can get in touch with you).
            <input type="email" name="presenter-email" />
        </label>
        <label>
            What is your session called?
            <input type="text" name="session-name" />
        </label>
        <input type="submit" value="Create Your Room" />
        <input type="hidden" name="nonce"
               value=" <?php echo $nonce; ?>" />
    </form><!--/#presenting-->

</section>

这个视图需要三个变量:

  • $join_action:允许用户加入现有房间的表单动作
  • $nonce:一个安全令牌,防止表单被欺诈或重复提交
  • $create_action:允许用户创建新房间的表单动作

两个表单中都使用了$nonce,因为没有办法同时提交两个表单(即使有,这个应用也不支持这种行为)。

生成输出变量

要创建输出主视图的变量,请返回主控制器(class.home.inc.php)并添加以下粗体代码:

    /**
     * Loads and outputs the view's markup
     *
     * @return void
     */
    public function output_view(  )
    {
        $view = new View('home');
        $view->nonce = $this->generate_nonce();

        // Action URIs for form submissions
        $view->join_action   = APP_URI . 'room/join';
        $view->create_action = APP_URI . 'room/create';

        $view->render();
    }

使用您在View类中创建的 setter,添加变量就像在View对象中声明新属性一样简单。

image 注意记住,这些属性被View class' render()方法转换成独立的变量,所以无论您为属性选择什么名称,都是视图可用的变量名(也就是说,$view->nonce作为$nonce对视图可用)。

让应用第一次旋转

随着主视图的完成,你的应用终于可以在浏览器中加载了。在你的浏览器中导航到应用——本书假设应用安装在http://rwa.local/——你会看到在第七章中设计的主页(见图 8-1 )。

9781430246206_Fig08-01.jpg

图 8-1。主视图,由应用生成

添加错误处理程序

接下来,应用需要一个错误处理程序。当前,如果访问了不工作的 URI(如http://rwa.loca/not-real/),会显示一条难看的“未发现异常”错误信息(见图 8-2 )。

9781430246206_Fig08-02.jpg

图 8-2。不工作的 URIs 导致显示难看的错误

如果出现问题,应用已经在尝试加载Error类,所以您需要做的就是构建控制器和视图来捕捉错误,并以易读的方式显示它们。

创建错误控制器

首先,创建一个名为class.error.inc.php的新文件,并将其保存在system/controllers/中。在内部放置以下代码:

<?php

/**
 * Processes output for the Room view
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Error extends Controller
{
    private $_message = NULL;

    /**
     * Initializes the view
     *
     * @param $options array    Options for the view
     * @return void
     */
    public function __construct( $options )
    {
        if (isset($options[1])) {
            $this->_message = $options[1];
        }
    }

    /**
     * Generates the title of the page
     *
     * @return string   The title of the page
     */
    public function get_title(  )
    {
        return 'Something went wrong.';
    }

    /**
     * Loads and outputs the view's markup
     *
     * @return void
     */
    public function output_view(  )
    {
        $view = new View('error');
        $view->message = $this->_message;
        $view->home_link = APP_URI;

        $view->render();
    }

}

Error 类有一点不同,它声明了一个私有属性$_message,该属性将存储Exception类的错误消息。

构造函数将提供的错误消息存储在$_message中(如果提供了一个的话),并且get_title()返回一个针对<title>标签的通用错误消息。

view()方法只是将错误消息和主页 URI 添加到视图对象中,以便在标记中使用;然后呈现输出。

创建错误视图

为了显示Error控制器的结果,在system/views/中创建一个名为error.inc.php的新文件,并添加以下标记:

<section id="error">

    <h2>
        I&rsquo;m sorry, Dave.<br />
        I&rsquo;m afraid I can&rsquo;t do that.
    </h2>

    <p>
        Sorry, but something went wrong. Maybe the error message below
        will help.
    </p>

    <p><code> <?php echo $message; ?></code></p>

    <p>
        <a href=" <?php echo $home_link; ?>">&larr; go back to the home page</a>
    </p>

</section>

$message变量输出提供给Exception的实际信息。“返回主页”链接使用$home_link链接到主页。

添加特定于错误的样式

错误页面需要对样式表进行一些小的调整,以便正确显示,所以打开assets/styles/main.css并在媒体查询上方添加以下内容:

/* Error Styling
 ----------------------------------------------------------------------------*/

section#error { text-align: center; }

section#error p { margin: 0 auto 2em; }

测试错误页面

为了验证一切正常,访问浏览器中的断开链接(http://rwa.local/not-real/)以查看错误页面(参见图 8-3 )。

9781430246206_Fig08-03.jpg

图 8-3。错误页面现在更容易阅读了

建立数据库

在我们可以在应用中走得更远之前,需要建立数据库。剩下的两个控制器——问题和房间——都存储数据,因此需要模型。

我们已经在第五章中讨论了如何构建数据库,所以我们将直接进入这里的代码。在 phpMyAdmin 中,终端,或者你喜欢的执行 MySQL 查询的方法,运行下面的命令:

CREATE TABLE IF NOT EXISTS 'presenters' (
  'id' int(11) NOT NULL AUTO_INCREMENT,
  'name' varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  'email' varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY ('id'),
  UNIQUE KEY 'email' ('email')
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE IF NOT EXISTS 'questions' (
  'id' int(11) NOT NULL AUTO_INCREMENT,
  'room_id' int(11) NOT NULL,
  'question' text COLLATE utf8_unicode_ci NOT NULL,
  'is_answered' tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY ('id'),
  KEY 'room_id' ('room_id')
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE IF NOT EXISTS 'question_votes' (
  'question_id' int(11) NOT NULL,
  'vote_count' int(11) NOT NULL,
  PRIMARY KEY ('question_id')
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE IF NOT EXISTS 'rooms' (
  'id' int(11) NOT NULL AUTO_INCREMENT,
  'name' varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  'is_active' tinyint(1) NOT NULL DEFAULT '1',
  PRIMARY KEY ('id')
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE IF NOT EXISTS 'room_owners' (
  'room_id' int(11) NOT NULL,
  'presenter_id' int(11) NOT NULL,
  KEY 'room_id' ('room_id','presenter_id')
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

这段代码创建了应用运行所需的数据库表。如果您在 phpMyAdmin 中查看数据库,您将看到新创建的表(参见图 8-4 )。

9781430246206_Fig08-04.jpg

图 8-4。在 phpMyAdmin 中查看的数据库表

处理表单提交

任何应用的主要组成部分之一是它接受表单提交的方式。为了最大限度地提高应用的效率,有必要花些时间来计划如何组织、清理、处理和存储所有表单提交。

规划表单提交工作流程

首先,我们需要建立表单提交工作流:

  1. 用户向适当的控制器提交一个表单,这由表单的action属性决定。
  2. 控制器识别表单提交并检查有效的动作。
  3. 如果找到了有效的动作,则使用 nonce 检查提交的合法性。
  4. 有效的提交被发送到操作指定的方法。
  5. handler 方法处理数据,将其交给模型进行存储,并返回一个布尔标志来指示成功或失败。
  6. 用户被重定向到正确的页面(或者,在许多情况下,页面只是用新数据更新)。

有了这个工作流,您现在可以开始充实表单提交过程的不同步骤了。

设置并检查有效动作

在本章的前面,你设置了抽象的Controller类,它有一个名为$actions的属性。这个属性将被RoomQuestion控制器用来定义一组动作和它们相应的方法。

每个控制器都有自己独特的动作,所以数组需要在Controller类的构造函数中声明。带有动作的示例控制器可能如下所示:

class Example extends Controller
{

    public function __construct( $options )
    {
        parent::__construct($options);

        $this->model = new Example_Model;

        $this->actions = array(
            'action-one' => 'say_foo',
        );

        if (array_key_exists($options[0], $this->actions)) {
            $this->handle_form_submission($options[0]);
            exit;
        }else {
            // If we get here, no valid form was submitted...
        }
    }

    /* get_title() and output_view() would go here */

}

上面重要的部分被加粗了。现在,忽略new Example_Model位;我们会在几页后讨论这个问题。

$actions数组被设置为一个键-值对,其中键是动作的名称(由提交 URI 触发),值是处理表单的方法的名称。

构造函数添加了一个if...else检查来查看是否到达了有效的表单提交 URI。如果是这样,它将触发尚未编写的handle_form_submission()方法。

要触发一个动作,表单需要提交给一个 URI,它有类名、一个正斜杠,然后是动作:

<form action="http://rwa.local/example/action-one">...</form>

处理动作的方法也需要添加到这个类中,但是我们将在本节的稍后部分讨论这一点。

防止重复或欺诈提交

为了防止错误的、重复的或欺诈性的表单提交,您需要实现一个nonce——或nnumber usedonce——以确保每个表单提交都来自一个有效的表单并且是第一次提交。

创建随机数

要创建一个 nonce,您需要做的就是为每个用户在每次页面加载时生成一个随机字符串。然后,这个随机数被添加为当前视图中加载的任何表单的隐藏表单字段,并存储在用户的会话中。

打开system/core/class.controller.inc.php并将以下粗体代码添加到generate_nonce()方法中:

protected function generate_nonce(  )
{
    // Checks for an existing nonce before creating a new one
    if (empty(self::$nonce)) {
       self::$nonce = base64_encode(uniqid(NULL, TRUE));
       $_SESSION['nonce'] = self::$nonce;
    }

    return self::$nonce;
}

该方法首先检查$nonce是否为空,因为 app 中经常会显示多个表单;如果第一个表单的 nonce 被覆盖,它就不能成功提交,这会破坏应用。

如果 nonce 没有设置,则通过生成一个uniqid()然后用base64_encode()对其进行编码来生成一个新的 nonce。这既作为静态属性存储在对象中(因此所有基于Controller的类在其视图中使用相同的 nonce ),也存储在$_SESSION超全局中,以便在提交后验证 nonce。

检查随机数

提交表单时,首先需要检查的是通过表单提交的随机数是否与会话中存储的随机数相匹配。如果它们不匹配,就说明有问题,提交的内容不应该被处理。

要检查 nonce,用下面的粗体代码向system/core/class.controller.inc.php添加一个名为check_nonce()的新方法:

protected function generate_nonce(  )
{
    // Checks for an existing nonce before creating a new one
    if (empty(self::$nonce)) {
       self::$nonce = base64_encode(uniqid(NULL, TRUE));
       $_SESSION['nonce'] = self::$nonce;
    }

    return self::$nonce;
}

/**
* Checks for a valid nonce
*
* @return bool     TRUE if the nonce is valid; otherwise FALSE
*/
protected function check_nonce(  )
{
    if (
        isset($_SESSION['nonce']) && !empty($_SESSION['nonce'])
        && isset($_POST['nonce']) && !empty($_POST['nonce'])
        && $_SESSION['nonce']===$_POST['nonce']
    ) {
        $_SESSION['nonce'] = NULL;
        return TRUE;
    } else {
        return FALSE;
    }
}

/**
* Performs basic input sanitization on a given string
*
 * @param $dirty    string  The string to be sanitized
* @return          string  The sanitized string
*/
protected function sanitize( $dirty )
{
    return htmlentities(strip_tags($dirty), ENT_QUOTES);
}

该方法检查三个标准:

  • 随机数存储在会话中
  • 随机数是和表单一起提交的
  • 会话和表单中的随机数是相同的

如果所有三个条件都满足,nonce 将从会话中删除(因此表单无法再次成功提交),并返回 Boolean TRUE以表示 nonce 检查成功。

编写表单处理方法

要实际处理表单提交,您需要三种方法:

  • 第一个将检查 nonce,执行 action handler 方法,并将用户重定向到正确的位置,等待成功或失败。
  • 第二个是前面提到的动作处理程序,它实际上处理提交的表单数据。
  • 第三种是模型方法,它从动作处理程序获取处理过的数据,并相应地操作数据库。

添加主窗体处理方法

第一个方法将驻留在system/core/class.controller.inc.php中,称为handle_form_submission()。它接受一个参数:动作。

将以下粗体代码添加到Controller类中:

protected function check_nonce(  )
{
    if (
        isset($_SESSION['nonce']) && !empty($_SESSION['nonce'])
        && isset($_POST['nonce']) && !empty($_POST['nonce'])
        && $_SESSION['nonce']===$_POST['nonce']
    ) {
        $_SESSION['nonce'] = NULL;
        return TRUE;
    } else {
        return FALSE;
    }
}

/**
 * Handles form submissions
 *
 * @param   $action string  The form action being performed
 * @return          void
 */
protected function handle_form_submission( $action )
{
    if ($this->check_nonce()) {

        // Calls the method specified by the action
        $output = $this->{$this->actions[$action]}();

        if (is_array($output) && isset($output['room_id'])) {
            $room_id = $output['room_id'];
        } else {
            throw new Exception('Form submission failed.');
        }

        header('Location: ' . APP_URI . 'room/' . $room_id);
        exit;
    } else {
        throw new Exception('Invalid nonce.');
    }
}

/**
 * Performs basic input sanitization on a given string
 *
 * @param $dirty    string  The string to be sanitized
 * @return          string  The sanitized string
 */
protected function sanitize( $dirty )
{
    return htmlentities(strip_tags($dirty), ENT_QUOTES);
}

该方法从验证随机数开始;然后,它调用操作处理程序方法,并将输出存储在一个变量中。对于这个应用,每个操作都将返回一个房间 ID,因此该方法检查以确保返回了一个房间 ID。使用房间 ID,用户然后被重定向到她应该查看的房间。

向控制器添加动作方法

为了实际处理提交的表单数据,需要向输出表单的Controller类添加一个新方法。使用我们之前的Example类,定义的两个动作需要将名为say_foo()say_bar()的方法添加到Example类中,这里用粗体显示:

class Example extends Controller
{

    public function __construct( $options )
    {
        parent::__construct($options);

        $this->actions = array(
            'action-one' => 'say_foo',
        );

        if (array_key_exists($options[0], $this->actions)) {
            $this->handle_form_submission($options[0]);
            exit;
        } else {
            // If we get here, no form was submitted...
        }
    }

    /* get_title() and output_view() would go here */

    protected function say_foo(  )
    {
        $room_id  = $this->sanitize($_POST['room_id']);
        $sayer_id = $this->sanitize($_POST['sayer_id']);

        echo 'Foo!';

        return $this->model->update_foo_count($room_id, $sayer_id);
    }

}

首先,这个方法获取从提交的表单传递来的任何数据,对其进行清理,并将其存储在一个变量中。尽管这个方法只是一个例子,但是$room_id被传递来跟随应用中存在的动作处理程序。

接下来,动作处理程序执行请求的动作:在本例中,输出字符串“Foo!”对着屏幕。

最后,它执行模型方法并返回结果。

向模型类添加方法

表单处理链的最后一步是添加一个模型方法。但首先,我们需要一个模型类。

幸运的是,这个应用将使事情变得简单,所以模型类简单地扩展了Model类,这使它们能够访问 PDO 驱动的数据库连接,然后声明所需的方法。不需要额外的设置。

继续我们的Example类,我们需要创建前面实例化的Example_Model类。该类需要的唯一方法是由动作处理程序方法调用的update_foo_count()方法,该方法只需要增加已经发生的“foo”的计数,并返回一个数据数组。

这看起来是这样的:

class Example_Model extends Model
{
    public function vote_question( $room_id, $sayer_id )
    {
        // Increments the vote count for the question
        $sql = "UPDATE sayings
                SET foo_count = foo_count+1
                WHERE sayer_id = :sayer_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':sayer_id', $sayer_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id'  => $room_id,
            'sayer_id' => $sayer_id,
        );
    }
}

这是将在此应用中构建的所有模型的基本模式。该方法创建一个 SQL 语句,使用 PDO 准备它,然后执行所需的任何数据库操作(在本例中是更新)。

执行查询后,将返回一个包含相关数据的数组。

image 注意该应用中的模型严重依赖于预处理语句,这比标准的 SQL 查询安全得多。要完成本书中的模型,你不需要知道比上一个例子中显示的更多的东西,但是如果你需要重温一下 PDO,请访问位于http://php.net/pdo的 PHP 手册。

摘要

这一章很难懂。至此,您已经成功地为您的应用构建了一个基于 MVC 原则的框架。

然而,在后端完成之前,您还有一段路要走。在下一章中,您将为房间和问题数据类型构建控制器、视图和模型。

1 RegEx 复杂到足以配得上它自己的书,这本书已经在http://www.regular-expressions.info/方便地在线编译了

2

3 菲尔不愿意。他计划。

4*

九、构建后端:第二部分

在上一章中,您构建了一个可工作的 MVC 框架,并将家庭控制器和视图放在一起。在本章中,您将通过构建QuestionRoom组件来完成应用的后端。本章使用了Model类,以及您在前一章中构建的表单提交处理。

构建问题

Room控制器之前构建Question控制器似乎有些落后,但是因为Room控制器需要来自Question控制器的几个标记片段来输出它的视图,所以让我们从这里开始,然后处理Room控制器。

构建问题控制器

首先,在system/controllers/中创建一个名为class.question.inc.php的新文件,并添加以下代码:

<?php

/**
 * Processes output for the Question view
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Question extends Controller
{

    public $room_id,
           $is_presenter = FALSE;

    /**
     * Initializes the class
     *
     * @param $options  array   Options for the controller
     * @return          void
     */
    public function __construct( $options )
    {
        parent::__construct($options);

        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }
    }

    /**
     * Generates the title of the page
     *
     * @return string   The title of the page
     */
    public function get_title(  )
    {
        // Questions can't be called directly, so this is unused
        return NULL;
    }

    /**
     * Loads and outputs the view's markup
     *
     * @return string   The HTML markup to display the view
     */
    public function output_view(  )
    {
        $questions = $this->get_questions();

        $output = NULL;
        foreach ($questions as $question) {

            /*
             * Questions have their own view type, so this section initializes
             * and sets up variables for the question view
             */
            $view = new View('question');
            $view->question     = $question->question;
            $view->room_id      = $this->room_id;
            $view->question_id  = $question->question_id;
            $view->vote_count   = $question->vote_count;

            if ($question->is_answered==1) {
                $view->answered_class = 'answered';
            } else {
                $view->answered_class = NULL;
            }

            // TODO: Check if the user has already voted up the question
            $view->voted_class = NULL;

            // TODO: Load the vote up form for attendees, but not presenters
            $view->vote_link = '';

            // TODO: Load the answer form for presenters, but not attendees
            $view->answer_link = '';

            // Returns the output of render() instead of printing it
            $output .= $view->render(FALSE);
        }

        return $output;
    }

}

这个方法还没有完成,但是构建模块已经就位,可以开始查看视图将如何形成。

构造函数触发主Controller构造函数,然后检查有效的房间 id,如果在 URI 中没有传递,则抛出一个错误。

因为问题永远不会单独显示——意思是在房间的上下文之外——get_title()方法只返回NULL。记住它不需要声明,因为它是抽象父类的一部分。

output_view()方法使用get_questions()方法加载房间的所有问题,稍后您将对其进行定义。然后它遍历每个问题,加载问题视图并用单个问题的数据填充它。一些变量需要更新;它们中的每一个都被标上了TODO的注释,所以以后回来写这些内容时,很容易就能发现它们。

添加问题视图

问题应用的视图看起来不太像;这只是你在第七章写的 HTML 的一个片段。然而,它有很多变数。

创建一个名为question.inc.php的新文件,并将其存储在system/views/中。在里面,添加以下内容:

<li id="question-<?php echo $question_id; ?>"
    data-count="<?php echo $vote_count; ?>"
    class="<?php echo $voted_class, ' ', $answered_class; ?>">
    <?php echo $answer_link; ?>
    <p>
        <?php echo $question; ?>
    </p>
    <?php echo $vote_link; ?>
</li><!--/#question-<?php echo $question_id; ?>-->

这个标记显示了在output_view()中设置的变量,如前所示。目前,这个视图看起来不太像,因为$voted_class$voted_link$answer_link都是NULL或空的。

完成视图

声明的几个变量是NULL或空的,因为检索所需数据的方法还不存在。根据注释中的TODO s,循环还需要:

  • 检查用户是否已经对问题投了赞成票
  • 为与会者而不是演示者加载“向上投票”表单
  • 为演示者而不是与会者加载答案表单

检查用户是否已经对某个问题投了赞成票

为了确定用户是否对某个问题投了赞成票,我们将使用一个简单的 cookie。当用户为一个问题投票时,会存储一个名为voted_for_n(其中 n 是问题的 ID)的 cookie。这将允许应用防止一个用户提交多个投票。

要检查 cookie,将以下粗体代码添加到output_view():

public function output_view(  )
{
    $questions = $this->get_questions();

    $output = NULL;
    foreach ($questions as $question) {

        /*
         * Questions have their own view type, so this section initializes
         * and sets up variables for the question view
         */
        $view = new View('question');
        $view->question     = $question->question;
        $view->room_id      = $this->room_id;
        $view->question_id  = $question->question_id;
        $view->vote_count   = $question->vote_count;

        if ($question->is_answered==1) {
            $view->answered_class = 'answered';
        } else {
            $view->answered_class = NULL;
        }

        // Checks if the user has already voted up this question
        $cookie = 'voted_for_' . $question->question_id;
        if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) {
            $view->voted_class = 'voted';
        } else {
            $view->voted_class = NULL;
        }

        // TODO: Load the vote up form for attendees, but not presenters
        $view->vote_link = '';

        // TODO: Load the answer form for presenters, but not attendees
        $view->answer_link = '';

        // Returns the output of render() instead of printing it
        $output .= $view->render(FALSE);
    }

    return $output;
}

这段代码检查一个 cookie,这个 cookie 表示这个问题已经被投票了,如果是的话,设置一个类名来改变样式。

image 警告在生产环境中,应用需要为关闭 cookie 的用户添加故障保护,或者在未启用 cookie 的情况下禁止用户使用应用。完全锁定表单提交超出了本书的范围,但是如果你想了解更多,网上有很多很好的资源。

加载向上投票表单

为了向与会者而不是演示者显示向上投票表单,需要创建一个新方法,该方法将有条件地生成一个视图,该视图可用于问题视图中的输出。

system/controllers/class.question.inc.php中,添加以下粗体代码:

public function output_view(  )
{
    $questions = $this->get_questions();

    $output = NULL;
    foreach ($questions as $question) {

        /*
         * Questions have their own view type, so this section initializes
         * and sets up variables for the question view
         */
        $view = new View('question');
        $view->question     = $question->question;
        $view->room_id      = $this->room_id;
        $view->question_id  = $question->question_id;
        $view->vote_count   = $question->vote_count;

        if ($question->is_answered==1) {
            $view->answered_class = 'answered';
        } else {
            $view->answered_class = NULL;
        }

        // Checks if the user has already voted up this question
        $cookie = 'voted_for_' . $question->question_id;
        if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) {
            $view->voted_class = 'voted';
        } else {
            $view->voted_class = NULL;
        }

        $view->vote_link = $this->output_vote_form(
            $this->room_id,
            $question->question_id,
            $question->is_answered
        );

            // TODO: Load the answer form for presenters, but not attendees
            $view->answer_link = '';

        // Returns the output of render() instead of printing it
        $output .= $view->render(FALSE);
    }

    return $output;
}

/**
 * Generates the voting form for attendees
 *
 * @param $question_id  int     The ID of the question
 * @param $answered     int     1 if answered, 0 if unanswered
 * @return              mixed   Markup if attendee, NULL if presenter
 */
protected function output_vote_form( $room_id, $question_id, $answered )
{
    $view = new View('question-vote');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/vote';
    $view->nonce       = $this->generate_nonce();
    $view->disabled    = $answered==1 ? 'disabled' : NULL;

    return $view->render(FALSE);
}

output_vote_form()方法接受三个参数:当前房间的 ID、当前问题的 ID 以及该问题是否已被回答。

然后它加载一个新的视图—question-vote—并为输出设置变量。房间 ID、问题 ID、表单动作和 nonce 以及$disabled都被存储,如果问题已经被标记为已回答,则防止表单被提交。

这个方法返回它的输出,存储在$vote_link变量中,在这里它成为问题视图的一部分。

添加向上投票表单视图

投票表单的视图非常简单。在system/views/中创建一个名为question-vote.inc.php的新文件,并添加以下标记:

<form method="post" class="vote"
      action="<?php echo $form_action; ?>">
    <input value="I also have this question."
           type="submit"<?php echo $disabled; ?>/>
    <input type="hidden" name="question_id"
           value="<?php echo $question_id; ?>" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form>

该标记使用在output_vote_form()中设置的变量来动态生成按钮,该按钮允许与会者提交对某个问题的投票。

加载答案表单

与 vote up 表单非常相似,您现在需要添加一个方法来加载答案表单,这允许演示者标记一个已回答的问题。将以下代码添加到Question类:

public function output_view(  )
{
    $questions = $this->get_questions();

    $output = NULL;
    foreach ($questions as $question) {

        /*
         * Questions have their own view type, so this section initializes
         * and sets up variables for the question view
         */
        $view = new View('question');
        $view->question     = $question->question;
        $view->room_id      = $this->room_id;
        $view->question_id  = $question->question_id;
        $view->vote_count   = $question->vote_count;

        if ($question->is_answered==1) {
            $view->answered_class = 'answered';
        } else {
            $view->answered_class = NULL;
        }

        // Checks if the user has already voted up this question
        $cookie = 'voted_for_' . $question->question_id;
        if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) {
            $view->voted_class = 'voted';
        } else {
            $view->voted_class = NULL;
        }

        $view->vote_link = $this->output_vote_form(
            $this->room_id,
            $question->question_id,
            $question->is_answered
        );

        $view->answer_link = $this->output_answer_form(
            $this->room_id,
            $question->question_id
        );

        // Returns the output of render() instead of printing it
        $output .= $view->render(FALSE);
    }

    return $output;
}

protected function output_vote_form( $room_id, $question_id, $answered )
{
    $view = new View('question-vote');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/vote';
    $view->nonce       = $this->generate_nonce();
    $view->disabled    = $answered==1 ? 'disabled' : NULL;

    return $view->render(FALSE);
}

/**
 * Generates the answering form for presenter
 *
 * @param $room_id      int     The ID of the room
 * @param $question_id  int     The ID of the question
 * @return              mixed   Markup if presenter, NULL if attendee
 */
protected function output_answer_form( $room_id, $question_id )
{
    $view = new View('question-answer');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/answer';
    $view->nonce       = $this->generate_nonce();

    return $view->render(FALSE);
}

该方法遵循与 output_vote_form() 相同的模式:它使用 question-answer 创建一个新视图,并设置用于生成标记的变量。

添加答案表单视图

创建一个名为question-answer.inc.php的新文件,并将其保存在system/views/中,其中包含以下标记:

<form method="post" class="answer"
      action="<?php echo $form_action; ?>">
    <input type="submit" value="Answer this question." />
    <input type="hidden" name="question_id"
           value="<?php echo $question_id; ?>" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form>

该标记使用在output_answer_form()中设置的变量来生成标记,供演示者将问题标记为已回答。

为投票和回答的问题添加样式

因为已经被投票或回答的问题不再是交互式的,按钮应该不再显示为可点击的。打开assets/styles/main.css并在媒体查询上方插入以下 CSS:

/* Voted and answered styles
 ----------------------------------------------------------------------------*/

#questions .voted .vote input {
    background-position: left bottom;
    width: 78px;
    cursor: initial;
}

#questions .answered .answer input:active,
#questions .answered .answer input:hover,
#questions .answered .answer input:focus {
    background-position: right top;
    cursor: initial;
}

#questions .answered .vote input:active,
#questions .answered .vote input:hover,
#questions .answered .vote input:focus {
    background-position: left bottom;
    cursor: initial;
}

/* Transition effects
 ----------------------------------------------------------------------------*/

#questions li,#questions .vote {
    -webkit-transition: opacity 1s ease-in-out;
       -moz-transition: opacity 1s ease-in-out;
        -ms-transition: opacity 1s ease-in-out;
         -o-transition: opacity 1s ease-in-out;
            transition: opacity 1s ease-in-out;
}

#questions.closed,#questions li.answered { opacity: .4; }

#questions.closed .vote,#questions .answered .vote { opacity: .2; }

这些样式防止按钮在悬停时高亮显示,并防止鼠标光标变成指针,这是元素可点击的标准指示。

当投票或回答问题时,过渡效果可以创建动画淡入淡出效果。在这种情况下,为了触发 CSS 转换,元素需要添加一个类,所以请记住,在下一章实现 realtime 和 jQuery 效果之前,转换是不可见的。

加载房间的所有问题

output_view()方法的最后一部分是目前不存在的方法get_questions()。实际的数据库查询将在本章稍后添加到问题模型中,但是现在,让我们在控制器中声明方法。

将以下粗体代码添加到Question类中:

public function output_ask_form( $is_active, $email )
{
    if ($is_active) {
        $view = new View('ask-form');
        $view->room_id     = $this->room_id;
        $view->form_action = APP_URI . 'question/ask';
        $view->nonce       = $this->generate_nonce();

        return $view->render(FALSE);
    } else {
        $view = new View('room-closed');
        $view->email = $email;

        return $view->render(FALSE);
    }
}

/**
 * Loads questions for the room
 *
 * @return array   The question data as an array of objects
 */
protected function get_questions(  )
{
    return $this->model->get_room_questions($this->room_id);
}

这个方法只是从 Question_Model 类中调用一个方法,这个类还没有被定义。一旦建立了模型,这个方法将返回给定房间 ID 的所有问题。

添加提问表单

除了投票和回答表单之外,还有一个表单需要添加到Question类中:用于提出新问题的表单。

添加提问方法

Question类中,使用下面的粗体代码添加新方法:

protected function output_answer_form( $room_id, $question_id )
{
    $view = new View('question-answer');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/answer';
    $view->nonce       = $this->generate_nonce();

    return $view->render(FALSE);
}

/**
 * Generates the form to ask a new question
 *
 * @param  $is_active   bool    Whether or not the room is active
 * @param  $email       string  The email address of the presenter
 * @return              string  The markup to display the form
 */
public function output_ask_form( $is_active, $email )
{
    if ($is_active) {
        $view = new View('ask-form');
        $view->room_id     = $this->room_id;
        $view->form_action = APP_URI . 'question/ask';
        $view->nonce       = $this->generate_nonce();

        return $view->render(FALSE);
    } else {
        $view = new View('room-closed');
        $view->email = $email;

        return $view->render(FALSE);
    }
}

这个方法与其他两个 form 方法非常相似,但是有一个重要的区别:根据房间是否活动,这个方法可以返回两个视图。

ask-form视图输出允许与会者提出新问题的表单。

room-closed视图使用演示者的电子邮件地址,允许任何进入封闭房间的人跟进任何其他问题。

添加提问视图

system/views/中创建一个名为ask-form.inc.php的新文件,并插入以下标记:

<form id="ask-a-question" method="post"
      action="<?php echo $form_action; ?>">
    <label>
        If you have a question and you don't see it below, ask it here.
        <input type="text" name="new-question" tabindex="1" />
    </label>
    <input type="submit" value="Ask" tabindex="2" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form><!--/#ask-a-question-->

这种标记创建了询问新问题的形式。

添加房间封闭视图

system/views/中,用以下标记添加一个名为room-closed.inc.php的新文件:

<h3>This session has ended.</h3>
<p>
    If you have a question that wasn't answered, please
    <a href="mailto:<?php echo $email; ?>">email the presenter</a>.
</p>

这种标记让与会者知道房间关闭了,但提供了一个电子邮件地址,以便与演示者联系,这样他就不会完全不走运。

构建问题模型

为了存储关于问题及其投票的数据,您现在需要创建一个模型类,它将包含所有与问题相关的数据库操作方法。

首先在system/models/中创建一个名为class.question_model.inc.php的新文件,其类定义如下:

<?php

/**
 * Creates database interaction methods for questions
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Question_Model extends Model
{

}

加载房间的所有问题

为了加载一个房间的所有问题,房间 ID 被传递给get_room_questions()方法。结果作为对象加载,然后传递回控制器进行处理。

为了按逻辑顺序检索问题(即,最高票数、未回答的问题显示在列表顶部),使用LEFT JOIN来利用来自question_votes的投票计数进行排序。

将以下粗体代码添加到Question_Model:

class Question_Model extends Model
{

    /**
     * Loads all questions for a given room
     *
     * @param   $room_id    int     The ID of the room
     * @return              array   The questions attached to the room
     */
    public function get_room_questions( $room_id )
    {
        $sql = "SELECT
                    id AS question_id,
                    room_id,
                    question,
                    is_answered,
                    vote_count
                FROM questions
                    LEFT JOIN question_votes
                        ON( questions.id = question_votes.question_id )
                WHERE room_id = :room_id
                ORDER BY is_answered, vote_count DESC";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $questions = $stmt->fetchAll(PDO::FETCH_OBJ);
        $stmt->closeCursor();

        return $questions;
    }

}

保存新问题

为了将新问题保存到数据库,房间 ID 和新问题文本(作为字符串)都被传递给create_question()方法。第一个查询将问题插入到questions表中,然后将新保存的问题的 ID 存储在$question_id中。

接下来,使用新创建的问题 ID 将问题的第一票(因为提问的用户算作第一票)添加到question_votes表中。

通过将以下粗体代码添加到Question_Model来实现该方法:

public function get_room_questions( $room_id )
{
    $sql = "SELECT
                id AS question_id,
                room_id,
                question,
                is_answered,
                vote_count
            FROM questions
                LEFT JOIN question_votes
                    ON( questions.id = question_votes.question_id )
            WHERE room_id = :room_id
            ORDER BY is_answered, vote_count DESC";
    $stmt = self::$db->prepare($sql);
    $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
    $stmt->execute();
    $questions = $stmt->fetchAll(PDO::FETCH_OBJ);
    $stmt->closeCursor();

    return $questions;
}

/**
 * Stores a new question with all the proper associations
 *
 * @param   $room_id    int     The ID of the room
 * @param   $question   string  The question text
 * @return              array   The IDs of the room and the question
 */
public function create_question( $room_id, $question )
{
    // Stores the new question in the database
    $sql = "INSERT INTO questions (room_id, question)
            VALUES (:room_id, :question)";
    $stmt = self::$db->prepare($sql);
    $stmt->bindParam(':room_id', $room_id);
    $stmt->bindParam(':question', $question);
    $stmt->execute();
    $stmt->closeCursor();

    // Stores the ID of the new question
    $question_id = self::$db->lastInsertId();

    /*
     * Because creating a question counts as its first vote, this adds a
     * vote for the question to the database
     */
    $sql = "INSERT INTO question_votes
            VALUES (:question_id, 1)";
    $stmt = self::$db->prepare($sql);
    $stmt->bindParam(":question_id", $question_id, PDO::PARAM_INT);
    $stmt->execute();
    $stmt->closeCursor();

    return array(
        'room_id'     => $room_id,
        'question_id' => $question_id,
    );
}

向问题添加投票

更新投票计数很简单:对于给定的问题 ID,vote_question()方法将投票计数增加1。将这个方法(粗体)添加到Question_Model类:

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

    /**
     * Increases the vote count of a given question
     *
     * @param   $room_id        int     The ID of the room
     * @param   $question_id    int     The ID of the question
     * @return                  array   The IDs of the room and the question
     */
    public function vote_question( $room_id, $question_id )
    {
        // Increments the vote count for the question
        $sql = "UPDATE question_votes
                SET vote_count = vote_count+1
                WHERE question_id = :question_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':question_id', $question_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

}

数据库查询通过在当前值vote_count = vote_count+1上加 1,将具有给定 ID 的问题的投票计数增加 1,然后返回房间和问题 ID。

将问题标记为已回答

最后,为了将问题标记为已回答,具有给定 ID 的问题的is_answered列被更新为1。将以下粗体代码添加到Question_Model :

        $stmt->closeCursor();

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

    /**
     * Marks a given question as answered
     *
     * @param   $room_id        int     The ID of the room
     * @param   $question_id    int     The ID of the question
     * @return                  array   The IDs of the room and question
     */
    public function answer_question( $room_id, $question_id )
    {
        $sql = "UPDATE questions
                SET is_answered = 1
                WHERE id = :question_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':question_id', $question_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

}

向控制器添加表单处理程序和数据访问方法

应用问题部分的最后一点是将动作数组、模型和动作处理程序类添加到Question控制器中。

首先用模型声明和动作数组更新构造函数。将以下内容添加到system/controllers/class.question.inc.php :

public function __construct( $options )
{
    parent::__construct($options);

    $this->model = new Question_Model;

    // Checks for a form submission
    $this->actions = array(
        'ask'    => 'create_question',
        'vote'   => 'vote_question',
        'answer' => 'answer_question',
    );

    if (array_key_exists($options[0], $this->actions)) {
        $this->handle_form_submission($options[0]);
        exit;
    } else {
        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }
    }
}

这将加载用于数据访问的Question_Model类,然后声明三种可能的表单动作及其所需的动作处理程序方法。

保存新问题

保存新问题的操作处理程序相当复杂,因为它是在下一章构建的——在下一章中,我们将开始添加实时功能——考虑在内。因此,它不仅存储新的问题,还生成新的问题视图以供返回,这样以后就不需要在客户端呈现标记了。它还为用户添加了一个 cookie,表明他们已经为这个问题投了票。

将下面的粗体代码添加到Question类中:

    protected function get_questions(  )
    {
        return $this->model->get_room_questions($this->room_id);
    }

    /**
     * Adds a new question to the database
     *
     * @return array    Information about the updated question
     */
    protected function create_question(  )
    {
        $room_id  = $this->sanitize($_POST['room_id']);
        $question = $this->sanitize($_POST['new-question']);

        $output = $this->model->create_question($room_id, $question);

        // Make sure valid output was returned
        if (is_array($output) && isset($output['question_id'])) {
            $room_id     = $output['room_id'];
            $question_id = $output['question_id'];

            // Generates markup for the question (for realtime addition)
            $view = new View('question');
            $view->question       = $question;
            $view->room_id        = $room_id;
            $view->question_id    = $question_id;
            $view->vote_count     = 1;
            $view->answered_class = NULL;
            $view->voted_class    = NULL;

            $view->vote_link = $this->output_vote_form(
                $room_id,
                $question_id,
                FALSE
            );

            $view->answer_link = $this->output_answer_form(
                $room_id,
                $question_id
            );

            $output['markup'] = $view->render(FALSE);
        } else {
            throw new Exception('Error creating the room.');
        }

        // Stores a cookie so the attendee can only vote once
        setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/');

        return $output;
    }

}

该方法首先净化提交的数据,使用模型的create_question()方法将其存储在数据库中,并检查有效的返回值。然后,它创建一个新的question视图,并存储所有变量,为一个新问题生成标记。存储了一个 cookie,表明与会者发布了问题的第一个向上投票;然后返回标记。

向问题添加投票

向一个问题添加新的投票会执行vote_question()方法,将新的投票存储在数据库中,然后为投票者设置一个 cookie,以防止对同一个问题进行多次投票。将以下粗体代码添加到Question控制器中:

        // Stores a cookie so the attendee can only vote once
        setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/');

        return $output;
    }

    /**
     * Increments the vote count for a given question
     *
     * @return array    Information about the updated question
     */
    protected function vote_question(  )
    {
        $room_id     = $this->sanitize($_POST['room_id']);
        $question_id = $this->sanitize($_POST['question_id']);

        // Makes sure the attendee hasn't already voted for this question
        $cookie_id = 'voted_for_' . $question_id;
        if (!isset($_COOKIE[$cookie_id]) || $_COOKIE[$cookie_id]!=1) {
            $output = $this->model->vote_question($room_id, $question_id);

            // Sets a cookie to make it harder to post multiple votes
            setcookie($cookie_id, 1, time() + 2592000, '/');
        } else {
            $output = array('room_id'=>$room_id);
        }

        return $output;
    }

}

将问题标记为已回答

为了将问题标记为已回答,提交表单的用户必须是演示者。这和投票一样,是基于 cookie 的。该方法在执行answer_question()方法之前检查演示者的 cookie。

将以下粗体显示的代码添加到Question控制器中:

        // Stores a cookie so the attendee can only vote once
        setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/');

        return $output;
    }

    /**
     * Marks a given question as answered
     *
     * @return array    Information about the updated question
     */
    protected function answer_question(  )
    {
        $room_id     = $this->sanitize($_POST['room_id']);
        $question_id = $this->sanitize($_POST['question_id']);

        // Makes sure the person answering the question is the presenter
        $cookie_id = 'presenter_room_' . $room_id;
        if (isset($_COOKIE[$cookie_id]) && $_COOKIE[$cookie_id]==1) {
            return $this->model->answer_question($room_id, $question_id);
        }

        return array('room_id'=>$room_id);
    }

}

该方法清理提交的表单值,然后检查演示者的 cookie,以验证当前用户是否有权将问题标记为已回答。如果 cookie 有效,模型的answer_question()方法被触发,其返回的数据被传递;无效或缺失的 cookie 只会将用户返回到房间,而不会通过返回房间 id 进行任何处理。

建造房间

这个应用的最后一部分是为房间添加控制器、模型和视图。这与问题的功能非常相似,除了它实际上加载了Question控制器来加载那些视图并利用它的方法。

添加房间控制器

第一步是创建Room控制器。在system/controllers/中,添加一个名为class.room.inc.php的新文件,并从以下代码开始:

<?php

/**
 * Processes output for the Room view
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Room extends Controller
{

    public $room_id,
           $is_presenter,
           $is_active;

    /**
     * Initializes the view
     *
     * @param $options array    Options for the view
     * @return void
     */
    public function __construct( $options )
    {
        parent::__construct($options);

        $this->model = new Room_Model;

        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }

        $this->room         = $this->model->get_room_data($this->room_id);
        $this->is_presenter = $this->is_presenter();
        $this->is_active    = (boolean) $this->room->is_active;
    }

    /**
     * Generates the title of the page
     *
     * @return string   The title of the page
     */
    public function get_title(  )
    {
        return $this->room->room_name . ' by ' . $this->room->presenter_name;
    }

    /**
     * Loads and outputs the view's markup
     *
     * @return void
     */
    public function output_view(  )
    {
        $view = new View('room');
        $view->room_id   = $this->room->room_id;
        $view->room_name = $this->room->room_name;
        $view->presenter = $this->room->presenter_name;
        $view->email     = $this->room->email;

        if (!$this->is_presenter) {
            $view->ask_form = $this->output_ask_form();
            $view->questions_class = NULL;
        } else {
            $view->ask_form = NULL;
            $view->questions_class = 'presenter';
        }

        if (!$this->is_active) {
            $view->questions_class = 'closed';
        }

        $view->controls  = $this->output_presenter_controls();
        $view->questions = $this->output_questions();

        $view->render();
    }

}

除了标准的构造函数——调用父构造函数,设置模型,并确保提供了有效的选项—Room构造函数还设置了一些特定于房间的属性。

$room属性将保存房间的基本信息,这些信息作为对象从get_room_data()方法返回。该模型将在本章后面的内容中进行设置以检索这些数据。

为了处理演示者和与会者在房间标记上的差异,$is_presenter保存了一个由is_presenter()方法确定的布尔值,稍后将编写这个方法。

最后,$is_active属性作为房间活动状态的快捷方式。因为它在数据库中存储为10,所以它被转换为布尔值,以允许在控制器的方法中进行严格的布尔比较。

get_title()方法使用房间和演示者的名字为房间生成一个有意义的标题。

output_view()中,加载了房间视图,并设置了一些变量,这将在本章的稍后部分进行介绍。

确定用户是否是演示者

因为向演示者显示的标记不同于向与会者显示的标记,所以应用需要一种方法来确定用户是否是当前房间的演示者。这将被存储为一个 cookie。

将以下粗体代码添加到Room控制器中:

        $view->controls  = $this->output_presenter_controls();
        $view->questions = $this->output_questions();

        $view->render();
    }

    /**
     * Determines whether or not the current user is the presenter
     *
     * @return boolean  TRUE if it's the presenter, otherwise FALSE
     */
    protected function is_presenter(  )
    {
        $cookie = 'presenter_room_' . $this->room->room_id;
        return (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1);
    }

}

该方法使用当前房间的 ID 计算出 cookie 名称,然后如果 cookie 设置为等于1,则返回TRUE

添加房间视图

使用第七章的中的房间标记,通过将以下代码添加到位于system/views/room.inc.php : 的新文件中来创建房间视图

<section>

    <header>
        <h2><?php echo $room_name; ?></h2>
        <p>
            Presented by<?php echo $presenter; ?>
            (<a href="mailto:<?php echo $email; ?>">email</a>)
        </p>
        <?php echo $controls; ?>
    </header>

    <?php echo $ask_form; ?>

    <ul id="questions" class="<?php echo $questions_class; ?>">
        <?php echo $questions; ?>
    </ul><!--/#questions-->

</section>

该标记生成向与会者和演示者显示房间及其问题所需的一切。

显示 Ask 表单

ask 表单需要一个新视图,该视图将由Room类中的一个新方法加载。如果您还记得,ask 表单的生成已经由Question控制器处理了,所以新方法将简单地调用Question控制器上的output_ask_form()方法。

将以下粗体代码添加到Room控制器中:

    $view->controls  = $this->output_presenter_controls();
    $view->questions = $this->output_questions();

    $view->render();
}

/**
 * Shows the "ask a question" form or a notice that the room has ended
 *
 * @param $email string The presenter's email address
 * @return string       Markup for the form or notice
 */
protected function output_ask_form(  )
{
    $controller = new Question(array($this->room_id));
    return $controller->output_ask_form(
        $this->is_active,
        $this->room->email
    );
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

显示演讲者控制

对于房间的展示者,我们需要提供房间的直接链接和关闭房间的选项(如果房间已经关闭,则重新打开房间)。为此,向Room控制器添加一个新方法,检查用户是否是演示者;然后检查该房间是否是活动的。

对于非活动房间,重新打开控件将被加载并返回,以便在主房间视图中使用。

活动房间加载标准控件并返回它们。

将以下粗体代码添加到class.room.inc.php:

    return $controller->output_ask_form(
        $this->is_active,
        $this->room->email
    );
}

/**
 * Shows the presenter his controls (or nothing, if not the presenter)
 *
 * @return mixed    Markup for the controls (or NULL)
 */
protected function output_presenter_controls(  )
{
    if ($this->is_presenter) {
        if (!$this->is_active) {
            $view_class  = 'presenter-reopen';
            $form_action = APP_URI . 'room/open';
        } else {
            $view_class  = 'presenter-controls';
            $form_action = APP_URI . 'room/close';
        }

        $view = new View($view_class);
        $view->room_id     = $this->room->room_id;
        $view->room_uri    = APP_URI . 'room/' . $this->room_id;
        $view->form_action = $form_action;
        $view->nonce       = $this->generate_nonce();

        return $view->render(FALSE);
    }

    return NULL;
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

添加不活跃的会议室演示者控制视图

非活动房间的视图是一个简单的表单,输入内容为“打开该房间”。

使用以下标记在system/views/中创建一个名为presenter-reopen.inc.php的文件:

<form id="close-this-room" method="post"
      action="<?php echo $form_action; ?>">
    <input type="submit" value="Open This Room" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form><!--/#close-this-room-->

添加活跃的会议室演示者控制视图

对于活动房间,显示带有房间 URI 的禁用输入,以及显示“关闭此房间”的按钮。

将以下标记添加到system/views/中名为presenter-controls.inc.php的新文件中:

<form id="close-this-room" method="post"
      action="<?php echo $form_action; ?>">
    <label>
        Link to your room.
        <input type="text" name="room-uri"
               value="<?php echo $room_uri; ?>"
               disabled />
    </label>
    <input type="submit" value="Close This Room" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form><!--/#close-this-room-->

展示问题

为了显示房间的问题,output_questions()方法将利用Question控制器遍历现有的问题并返回标记。

在生成标记之前,它设置了$is_presenter属性,以便为用户类型返回正确的标记。

将以下粗体代码添加到Room控制器中:

        return $view->render(FALSE);
    }

    return NULL;
}

/**
 * Loads and formats the questions for this room
 *
 * @return string   The marked up questions
 */
protected function output_questions(  )
{
    $controller = new Question(array($this->room_id));

    // Allows for different output for presenters vs. attendees
    $controller->is_presenter = $this->is_presenter;

    return $controller->output_view();
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

建立房间模型

因为应用需要存储和操作房间数据,所以您需要为房间创建一个模型,名为Room_Model。这将存储在名为class.room_model.inc.php的文件的system/models/子目录中。

创建文件,并以这个基本的类定义开始:

<?php

/**
 * Creates database interaction methods for rooms
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Room_Model extends Model
{

}

创建房间

模型的第一种方法是将新房间保存到数据库中。

这是一个多步骤的过程;该方法需要做以下工作:

  • rooms表格中创建新房间
  • 检索新房间的 ID
  • 将演示者添加到presenters表中(或者在电子邮件重复的情况下更新演示者的显示名称)
  • presenters表中检索演示者的 ID
  • 将房间 ID 映射到room_owners表中演示者的 ID。

将以下粗体代码添加到房间模型中:

class Room_Model extends Model
{

    /**
     * Saves a new room to the database
     *
     * @param   $presenter  string  The name of the presenter
     * @param   $email      string  The presenter's email address
     * @param   $name       string  The name of the room
     * @return              array   An array of data about the room
     */
    public function create_room( $presenter, $email, $name )
    {
        // Creates a new room
        $sql = 'INSERT INTO rooms (name) VALUES (:name)';
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':name', $name, PDO::PARAM_STR, 255);
        $stmt->execute();
        $stmt->closeCursor();

        // Gets the generated room ID
        $room_id = self::$db->lastInsertId();

        // Creates (or updates) the presenter
        $sql = "INSERT INTO presenters (name, email)
                VALUES (:name, :email)
                ON DUPLICATE KEY UPDATE name=:name";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':name', $presenter, PDO::PARAM_STR, 255);
        $stmt->bindParam(':email', $email, PDO::PARAM_STR, 255);
        $stmt->execute();
        $stmt->closeCursor();

        // Gets the generated presenter ID
        $sql = "SELECT id
                FROM presenters
                WHERE email=:email";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':email', $email, PDO::PARAM_STR, 255);
        $stmt->execute();
        $pres_id = $stmt->fetch(PDO::FETCH_OBJ)->id;
        $stmt->closeCursor();

        // Stores the room:presenter relationship
        $sql = 'INSERT INTO room_owners (room_id, presenter_id)
                VALUES (:room_id, :pres_id)';
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(":room_id", $room_id, PDO::PARAM_INT);
        $stmt->bindParam(":pres_id", $pres_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

}

检查房间是否存在

作为加入房间过程的一部分,Room控制器需要能够验证房间的存在。这个方法简单地选择与给定 ID 匹配的rooms表中的COUNT()个房间;计数1表示房间存在,0表示不存在。

将以下粗体代码添加到Room_Model:

        // Stores the room:presenter relationship
        $sql = 'INSERT INTO room_owners (room_id, presenter_id)
                VALUES (:room_id, :pres_id)';
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(":room_id", $room_id, PDO::PARAM_INT);
        $stmt->bindParam(":pres_id", $pres_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

    /**
     * Checks if a given room exists
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              bool    Whether or not the room exists
     */
    public function room_exists( $room_id )
    {
        // Loads the number of rooms matching the provided room ID
        $sql = "SELECT COUNT(id) AS the_count FROM rooms WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $room_exists = (bool) $stmt->fetch(PDO::FETCH_OBJ)->the_count;
        $stmt->closeCursor();

        return $room_exists;
    }

}

使用(bool)将计数显式转换为布尔值意味着该方法将总是返回TRUEFALSE,这对于唯一目的是检查某个东西是否存在的方法很有帮助。

开房

对于一个已经关闭的房间,重新打开它非常简单,只需将具有给定 ID 的房间的rooms表中的is_active列设置为1

将粗体显示的代码添加到class.room_model.inc.php :

        $room_exists = (bool) $stmt->fetch(PDO::FETCH_OBJ)->the_count;
        $stmt->closeCursor();

        return $room_exists;
    }

    /**
     * Sets a given room's status to "open"
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              array   An array of data about the room
     */
    public function open_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=1 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

}

关闭房间

关闭一个房间与打开一个房间的过程相同,只是在rooms表中is_active列被设置为0。将以下粗体代码插入Room_Model :

    public function open_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=1 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

    /**
     * Sets a given room's status to "closed"
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              array   An array of data about the room
     */
    public function close_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=0 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

}

装货间信息

加载房间信息可以说是应用中最复杂的查询。它要求如下:

  • room_owners工作台连接到rooms工作台;然后将presenters表连接到该表,形成一个完整的数据集
  • rooms表中加载idnameis_active
  • id更名为room_id,将name更名为room_name
  • 从演示者表格中加载idnameemail
  • id更名为presenter_id,将name更名为presenter_name

使用添加到class.room_model.inc.php中的以下粗体代码所示的查询完成上述步骤:

    public function close_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=0 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

    /**
     * Retrieves details about a given room
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              array   An array of data about the room
     */
    public function get_room_data( $room_id )
    {
        $sql = "SELECT
                    rooms.id AS room_id,
                    presenters.id AS presenter_id,
                    rooms.name AS room_name,
                    presenters.name AS presenter_name,
                    email, is_active
                FROM rooms
                LEFT JOIN room_owners
                    ON( rooms.id = room_owners.room_id )
                LEFT JOIN presenters
                    ON( room_owners.presenter_id = presenters.id )
                WHERE rooms.id = :room_id
                LIMIT 1";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $room_data = $stmt->fetch(PDO::FETCH_OBJ);
        $stmt->closeCursor();

        return $room_data;
    }

}

向房间控制器添加表单处理程序

应用后端的最后一步是将表单动作和动作处理程序添加到Room控制器中。

将表单动作添加到房间控制器

Room控制器支持四种动作:

  • 加入房间
  • 创建房间
  • 开房
  • 关闭房间

将以下粗体代码添加到Room类的构造函数中,以添加对这四个动作的支持:

public function __construct( $options )
{
    parent::__construct($options);

    $this->model = new Room_Model;

    // Checks for a form submission
    $this->actions = array(
        'join'   => 'join_room',
        'create' => 'create_room',
        'open'   => 'open_room',
        'close'  => 'close_room',
    );

    if (array_key_exists($options[0], $this->actions)) {
        $this->handle_form_submission($options[0]);
        exit;
    } else {
        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }
    }

    $this->room         = $this->model->get_room_data($this->room_id);
    $this->is_presenter = $this->is_presenter();
    $this->is_active    = (boolean) $this->room->is_active;
}

该数组为应该调用的方法创建一个操作映射。接下来,if...else语句检查一个有效的动作,如果传递了一个动作,它就调用handle_form_submission()来处理它。

加入房间

当用户试图加入一个房间时,控制器需要首先使用来自Room_Modelroom_exists()方法检查它是否存在。如果是,用户应该被重定向到所请求的房间;否则,他们应该会收到一条错误消息。

将以下粗体代码添加到Room控制器中:

protected function output_questions(  )
{
    $controller = new Question(array($this->room_id));

    // Allows for different output for presenters vs. attendees
    $controller->is_presenter = $this->is_presenter;

    return $controller->output_view();
}

/**
 * Checks if a room exists and redirects the user appropriately
 *
 * @return void
 */
protected function join_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);

    // If the room exists, creates the URL; otherwise, sends to a 404
    if ($this->model->room_exists($room_id)) {
        $header = APP_URI . 'room/' . $room_id;
    } else {
        $header = APP_URI . 'no-room';
    }

    header("Location: " . $header);
    exit;
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

创建新房间

要创建一个新房间,控制器需要整理演示者的姓名、电子邮件和房间名称;使用房间模型的create_room()方法存储它们;然后检查房间是否创建成功。它还应该设置一个 cookie,将房间的创建者标识为演示者。

class.room.inc.php中,添加以下粗体代码:

    if ($this->model->room_exists($room_id)) {
        $header = APP_URI . 'room/' . $room_id;
    } else {
        $header = APP_URI . 'no-room';
    }

    header("Location: " . $header);
    exit;
}

/**
 * Creates a new room and sets the creator as the presenter
 *
 * @return array Information about the updated room
 */
protected function create_room(  )
{
    $presenter = $this->sanitize($_POST['presenter-name']);
    $email     = $this->sanitize($_POST['presenter-email']);
    $name      = $this->sanitize($_POST['session-name']);

    // Store the new room and its various associations in the database
    $output = $this->model->create_room($presenter, $email, $name);

    // Make sure valid output was returned
    if (is_array($output) && isset($output['room_id'])) {
        $room_id = $output['room_id'];
    } else {
        throw new Exception('Error creating the room.');
    }

    // Makes the creator of this room its presenter
    setcookie('presenter_room_' . $room_id, 1, time() + 2592000, '/');

    return $output;
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

重新打开一个封闭的房间

为了重新打开一个已经关闭的房间,清理后的房间 ID 被传递给模型的open_room()方法。

    // Makes the creator of this room its presenter
    setcookie('presenter_room_' . $room_id, 1, time() + 2592000, '/');

    return $output;
}

/**
 * Marks a given room as active
 *
 * @return array Information about the updated room
 */
protected function open_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);
    return $this->model->open_room($room_id);
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

关闭房间

关闭一个房间和打开一个房间几乎是一样的,只是调用了close_room()方法。

protected function open_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);
    return $this->model->open_room($room_id);
}

/**
 * Marks a given room as closed
 *
 * @return array Information about the updated room
 */
protected function close_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);
    return $this->model->close_room($room_id);
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

全部测试完毕

此时,您的应用代码已经完成,可以进行测试了。让我们运行每一个可用的行动,以验证一切都在按计划进行。

创建您的第一个房间

首先,在您选择的浏览器中加载应用,并输入新房间的详细信息(参见图 9-1 )。

9781430246206_Fig09-01.jpg

图 9-1。在应用的主页上创建新房间

点击创建你的房间,你将被带到新的空房间(见图 9-2 )。

9781430246206_Fig09-02.jpg

图 9-2。新创建的房间

image 注意在一些服务器配置中,运行PDO::__construct()时可能会出现 MySQL 错误。这通常意味着您需要将您的php.ini文件指向mysql.sock的正确位置。在https://gist.github.com/jlengstorf/5184301有一个简单的演练,如果您在 Google 上搜索错误消息,还有几个可用的演练。

关闭房间

关闭房间,测试演示者控制是否正常工作。点击关闭此房间按钮,房间变为非活动状态(参见图 9-3 )。

9781430246206_Fig09-03.jpg

图 9-3。封闭的房间

重新开放房间

确保房间可以通过点击打开此房间按钮重新打开,这将使房间回到其最初的活动状态(参见图 9-4 )。

9781430246206_Fig09-04.jpg

图 9-4。重新开放的房间

加入房间

接下来,打开不同的浏览器(这意味着完全不同的应用:Firefox、Safari、Opera 或 Internet Explorer,如果你开始使用 Google Chrome 的话)并导航到http://rwa.local/

根据本节中的数字,输入 1 作为房间的 ID,并点击加入此房间按钮(参见图 9-5 )。

9781430246206_Fig09-05.jpg

图 9-5。从主页加入房间(使用不同的浏览器)

房间打开,您现在可以看到“提问”表单(参见图 9-6 )。

9781430246206_Fig09-06.jpg

图 9-6。您作为与会者查看房间时,会看到“提问”表单

问你的第一个问题

通过在表格字段中键入新问题,测试与会者是否可以提出新问题(参见图 9-7 )。

9781430246206_Fig09-07.jpg

图 9-7。通过“提问”表单提出新问题

点击提问按钮后,新问题被创建并显示为已被您投票通过(见图 9-8 )。

9781430246206_Fig09-08.jpg

图 9-8。问题被创建;创造者的投票已经被计算了

投票表决这个问题

为了测试对问题的投票,您需要打开第三个浏览器,这样您就可以作为尚未对新问题投票的与会者加入房间(参见图 9-9 )。

9781430246206_Fig09-09.jpg

图 9-9。未投票的与会者看到的问题

点击投票按钮,将投票数增加 1(见图 9-10 )。

9781430246206_Fig09-10.jpg

图 9-10。收到第二次投票后的问题

回答问题

回到第一个浏览器——您的用户是演示者的浏览器——重新加载以查看新问题(参见图 9-11 )。

9781430246206_Fig09-11.jpg

图 9-11。演示者仪表盘上的问题,完成计票

现在点击回答按钮,将问题标记为已回答(参见图 9-12 )。

9781430246206_Fig09-12.jpg

图 9-12。演示者眼中的已回答问题

通过检查其他两个浏览器中的任何一个,验证这也能正确显示给与会者(参见图 9-13 )。

9781430246206_Fig09-13.jpg

图 9-13。与会者眼中的已回答问题

摘要

从这一章到上一章,你已经在最后 100 页左右的篇幅里涉及了很多内容。您现在应该有一个功能齐全的 MVC 应用,允许创建、关闭和重新打开房间;以及提问、投票和回答问题。

在下一章中,您将向应用添加实时事件通知,并实现 JavaScript 效果来动画显示这些事件。

十、实现实时事件和 jQuery 效果

至此,你有了一个全功能的 app。然而,为了让这个特殊的应用有用,它需要实现实时功能,这样它的用户就不会被迫不断地重新加载以获取新数据。

在这一章中,你将把应用挂接到 Pusher 上,并在后端添加代码来创建实时事件。您还将使用 Pusher 的 JavaScript 应用编程接口(API)来订阅这些事件,并使用 jQuery 来制作应用的动画,这样就可以在屏幕上以一种和谐的方式操作新数据。

哪些事件需要实时增强?

  • 关闭房间
  • 开房
  • 问问题
  • 投票表决一个问题
  • 回答问题

添加所需的凭证和库

在开始添加 realtime 之前,您需要确保手头上有所有适当的凭据和库来配置应用。

获取您的 Pusher API 证书

Pusher API 要求您的应用使用应用密钥、应用密码和应用 ID 进行身份验证。

要获取它们,请在http://pusher.com登录您的帐户,并从您的仪表板左上角选择“添加新应用”。将你的新应用命名为“实时网络应用”,不要勾选下面的两个框(见图 10-1 )。

9781430246206_Fig10-01.jpg

图 10-1。在 Pusher 中创建新应用

点击“创建应用”,然后在下一个屏幕上点击“API 访问”,调出您的 API 凭证(见图 10-2 )。

9781430246206_Fig10-02.jpg

图 10-2。在 Pusher 仪表盘上查看应用的 API 凭证

将推送器 API 凭证添加到配置文件

现在您已经有了 API 凭证,它们需要包含在应用中。为此,向system/config/config.inc.php添加三个新的常量——以粗体显示:

//-----------------------------------------------------------------------------
// Database credentials
//-----------------------------------------------------------------------------

$_C['DB_HOST'] = 'localhost';
$_C['DB_NAME'] = 'rwa_app';
$_C['DB_USER'] = 'root';
$_C['DB_PASS'] = '';

//-----------------------------------------------------------------------------
// Pusher credentials
//-----------------------------------------------------------------------------

$_C['PUSHER_KEY']    = '9570a71016cf9861a52b';
$_C['PUSHER_SECRET'] = '65cc09ede8e2c18701cc';
$_C['PUSHER_APPID']  = '37506';

//-----------------------------------------------------------------------------
// Enable debug mode (strict error reporting)
//-----------------------------------------------------------------------------

$_C['DEBUG'] = TRUE;

为 Pusher 下载 PHP API 包装

对于应用的后端部分,我们需要使用 API 包装器来使访问 Pusher 变得轻而易举。

https://github.com/pusher/pusher-php-server下载 API 包装。ZIP 将包含几个文件和目录,但你需要抓取的唯一一个是在lib/Pusher.php,你现在应该在你的应用中复制到system/lib/

包括应用中的 PHP API 包装器

既然 Pusher API 包装器已经在应用的目录结构中,就需要包含它以供使用。在index.php中,将以下粗体代码添加到初始化块中:

// Starts the session
if (!isset($_SESSION)) {
    session_start();
}

// Loads the configuration variables
require_once SYS_PATH . '/config/config.inc.php';

// Loads Pusher
require_once SYS_PATH . '/lib/Pusher.php';

// Turns on error reporting if in debug mode
if (DEBUG===TRUE) {
    ini_set('display_errors', 1);
    error_reporting(E_ALL^E_STRICT);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

加载 Pusher 的 JavaScript API 包装器

对于实时实现的前端部分,应用需要包含 Pusher 的 JavaScript API 包装器。在system/inc/footer.inc.php中,添加以下粗体代码:

    </ul>
</footer>

<script src="[`js.pusher.com/1.12/pusher.min.js"></script`](http://js.pusher.com/1.12/pusher.min.js"></script)>

</body>

</html>

正在加载 jQuery

为了获得效果,您的应用将需要 jQuery 库。将其添加到页脚中的 Pusher JS 之后:

    </ul>
</footer>

<script src="http://js.pusher.com/1.12/pusher.min.js"></script>
<script src="[`code.jquery.com/jquery-1.8.2.min.js"></script`](http://code.jquery.com/jquery-1.8.2.min.js"></script)>

</body>

</html>

image 注意该应用中的代码只经过了 jQuery 1 . 8 . 2 版本的测试。较新的版本可能会带来一些问题,只有经过全面测试后才能使用。

在后端实现实时

为了让球滚动起来,需要在应用的后端创建和触发事件。

创建事件

由于应用处理表单提交的方式——所有表单都通过一个表单处理程序方法传递——发送实时事件通知只需要几行代码,这些代码将被添加到抽象Controller类中的handle_form_submission()方法中。

打开system/core/class.controller.inc.php并插入以下粗体代码:

protected function handle_form_submission( $action )
{
    if ($this->check_nonce()) {

       // Calls the method specified by the action
       $output = $this->{$this->actions[$action]}();

       if (is_array($output) && isset($output['room_id'])) {
          $room_id = $output['room_id'];
       } else {
          throw new Exception('Form submission failed.');
       }

       // Realtime stuff happens here
       $pusher  = new Pusher(PUSHER_KEY, PUSHER_SECRET, PUSHER_APPID);
       $channel = 'room_' . $room_id;
       $pusher->trigger($channel, $action, $output);

       header('Location: ' . APP_URI . 'room/' . $room_id);
       exit;
   } else {
       throw new Exception('Invalid nonce.');
    }
}

创建一个新的Pusher对象并存储在$pusher变量中,然后使用其 ID 创建房间的通道。使用动作名作为事件名,使用发送输出数组供客户端使用的trigger()方法在房间的通道上触发一个新事件。

测试实时事件

为了确保你的实时事件在后端被触发,回到你的 Pusher 仪表盘,打开你的应用的调试控制台。打开此页面,在新标签或浏览器中导航至http://rwa.local,然后创建一个新房间。

创建房间后,查看调试控制台,您会看到类似于图 10-3 的内容。

9781430246206_Fig10-03.jpg

图 10-3。推进器调试控制台实时显示房间的创建

接下来,关闭房间;然后重新打开它。再次检查控制台,您将看到closeopen事件被触发(参见图 10-4 )。

9781430246206_Fig10-04.jpg

图 10-4。Pusher 控制台显示关闭和重新打开房间的事件

在第二个浏览器中,加入房间(本例中为 ID 5)并提出问题。新的连接以及触发的ask事件显示在控制台上(见图 10-5 )。

9781430246206_Fig10-05.jpg

图 10-5。控制台显示新的问题标记,由 ask 事件发送

在第三个浏览器中,对问题进行投票,以查看触发的投票事件(参见图 10-6 )。

9781430246206_Fig10-06.jpg

图 10-6。控制台显示发生的投票事件

最后,返回到创建房间的浏览器,将问题标记为已回答。这触发了控制台中的answer事件(参见图 10-7 )。

9781430246206_Fig10-07.jpg

图 10-7。控制台中的回答事件

在前端实现实时

现在,应用的后端正在触发事件,前端需要监听它们。

订阅频道

第一步是创建一个 JavaScript Pusher对象,并用它来订阅房间的频道。

确定通道名称

在订阅频道之前,您首先需要创建一个新的模板变量来保存正确的频道名称。在index.php中,添加以下粗体代码以生成通道名称:

require_once SYS_PATH . '/inc/header.inc.php';

$controller->output_view();

// Configures the Pusher channel if we're in a room
$channel = !empty($uri_array[0]) ? 'room_' . $uri_array[0] : 'default';

require_once SYS_PATH . '/inc/footer.inc.php';

添加频道订阅 JavaScript

现在频道名称已经确定,创建一个新的Pusher对象,并通过在system/inc/footer.inc.php中添加以下粗体代码来订阅该频道:

</footer>

<script src="http://js.pusher.com/1.12/pusher.min.js"></script>
<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>
<script>
    var pusher  = new Pusher('<?php echo PUSHER_KEY; ?>'),
        channel = pusher.subscribe('<?php echo $channel; ?>');
</script>

</body>

</html>

绑定到事件

该应用现在订阅了一个频道,但此时它仍需要监听各个事件。

创建一个初始化 JavaScript 文件

为了保持页脚的整洁,在assets/scripts/中创建一个名为init.js的新文件,并用下面的代码初始化它:

/**
 * Initialization script for Realtime Web Apps
 */
(function($) {

})(jQuery);

这个文件将包含应用的 JavaScript 的其余部分。

image 提示用闭包[脚注]包装你的应用脚本可以防止与其他使用美元符号($)快捷方式的库发生冲突,比如 Prototype 和 MooTools。

通过将以下粗体代码插入页脚,将此文件加载到您的应用中:

<script src="http://js.pusher.com/1.12/pusher.min.js"></script>
<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>
<script>
    var pusher  = new Pusher('<?php echo PUSHER_KEY; ?>'),
        channel = pusher.subscribe('<?php echo $channel; ?>');
</script>
<script src="<?php echo APP_URI; ?>assets/scripts/init.js"></script>

</body>

</html>

image 注意Pusher对象初始化和通道订阅被直接放在页脚中,以利用 PHP 支持的模板。

为每个支持的动作添加事件绑定

对于每一个需要实时响应的动作,我们的应用都会触发一个需要“监听”的事件。Pusher 通过bind()方法使这变得非常容易,这对于任何在以前的项目中使用过 JavaScript 的开发人员来说都应该很熟悉。

bind()方法将被监听的事件的名称作为第一个参数,将事件发生时要执行的函数作为第二个参数。

使用以下粗体代码为应用中的每个事件绑定一个函数:

(function($) {

    channel.bind('close',  function(data){  });
    channel.bind('open',   function(data){  });
    channel.bind('ask',    function(data){  });
    channel.bind('vote',   function(data){  });
    channel.bind('answer', function(data){  });

})(jQuery);

这些绑定实际上不会增加应用的开销,因此同时绑定所有五个不会影响性能。

image 注意这些方法现在还不做任何事情;您将在下一节中添加该功能。

添加效果

该应用现在发送和接收实时事件,所以剩下要做的就是添加新数据的效果。

处理房间事件

当演示者关闭一个房间时,需要立即让与会者知道,这样他们就不会试图问任何新问题或投新票。

类似地,如果一个演示者重新打开一个房间,所有与会者都应该立即知道这个房间现在又打开了。

由于标记在封闭的房间和开放的房间之间变化很大,所以提醒与会者房间的开放状态发生变化的最直接、最不容易出错的方法是简单地刷新页面。

init.js,创建一个名为room的新对象,它有两个方法:open()close()。两者都应该在被调用时重新加载页面。

相应的事件也应该触发这些方法。为此,将以下粗体代码添加到init.js:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){  });
    channel.bind('vote',   function(data){  });
    channel.bind('answer', function(data){  });

    var room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        };

})(jQuery);

请注意,我们已经选择在演示者关闭房间时自动重新加载页面。通常情况下,你不希望在没有用户输入的情况下重新加载一个页面,因为这可能会造成混乱,但是我们有充分的理由这样做。在这种特殊情况下,如前所述,当房间关闭时,页面上的许多标记会发生变化。此外,用户不能向关闭的房间提交新问题,因此关闭房间的演示者应该是破坏性的;否则,与会者可能会花额外的时间处理一个无论如何都无法提交的问题,这可能比仅仅意识到房间现在已经关闭,他们应该给演示者发电子邮件更令人沮丧。

添加带动画的新问题

当一个新问题被提出时,它应该立即提供给查看房间的每个人。为了使新数据的引入不那么刺耳,应该添加一个动画。

在第八章中,当动作处理器方法create_question()被添加到Question控制器中以询问新问题时,您已经为该事件生成了视图,因此格式化的 HTML 将在data对象中发送。

但是,因为后端不可能知道视图当前使用的随机数,所以我们需要读取随机数并将其插入到所有新生成的随机数字段的value属性中,然后用slideDown()将问题添加到列表的顶部:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){ question.ask(data); });
    channel.bind('vote',   function(data){  });
    channel.bind('answer', function(data){  });

    varnonce = $('input[name=nonce]:eq(0)').val(),
        room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        },
        question = {
            ask: function(data){
                $(data.markup)
                    .find('input[name=nonce]').val(nonce).end()
                    .hide().prependTo('#questions').slideDown('slow');
            }
        };

})(jQuery);

image 注意为了保持 JavaScript 简洁,我们在这个脚本中对所有三个变量只使用了一次var声明。这主要是一种风格上的选择,但是也有一些观点认为使用这种方法可以获得微小的性能提升。

为问题添加投票

当与会者对某个问题进行投票时,投票按钮旁边的计数应该会更新。然而,我们想吸引更多的注意力,所以让我们添加一个微妙的动画。

将动画添加到样式表

因为 CSS3 引入了动画,并且因为大多数现代浏览器支持 CSS3 动画的硬件加速,所以您的应用将使用关键帧 CSS 动画,而不是 jQuery 动画。

为了实现这一点,您首先必须确定哪个类(在本例中为.new-vote)将触发动画,然后设置它。对于这个名为vote的动画,我们将快速淡出问题,然后通过调整不透明度再次淡入。这将被执行或迭代两次。

不幸的是,您将需要特定于供应商的前缀来确保动画在所有浏览器中都能工作,所以应该是快速添加的内容变成了相当大量的 CSS。

将以下代码添加到assets/styles/main.css的底部:

/*
 * ANIMATION
 *****************************************************************************/

#questions li.new-vote {
    -webkit-animation-name: vote;
    -webkit-animation-duration: 0.5s;
    -webkit-animation-timing-function: ease-in-out;
    -webkit-animation-iteration-count: 2;

    -moz-animation-name: vote;
    -moz-animation-duration: 0.5s;
    -moz-animation-timing-function: ease-in-out;
    -moz-animation-iteration-count:2;

    -ms-animation-name: vote;
    -ms-animation-duration: 0.5s;
    -ms-animation-timing-function: ease-in-out;
    -ms-animation-iteration-count: 2;

    animation-name: vote;
    animation-duration: 0.5s;
    animation-timing-function: ease-in-out;
    animation-iteration-count: 2;
}

@-webkit-keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

@-moz-keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

@-ms-keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

@keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

用一个类触发动画

现在动画已经就绪,JavaScript 需要做的就是添加一个类来触发它。

除了动画之外,脚本还需要更新投票计数,因为可能会有多个人为一个问题投票,所以在动画完成后删除该类,以便它可以被多次触发。

将以下粗体代码添加到init.js以完成投票效果:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){ question.ask(data); });
    channel.bind('vote',   function(data){ question.vote(data); });
    channel.bind('answer', function(data){  });

    var nonce = $('input[name=nonce]:eq(0)').val(),
        room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        },
        question = {
            ask: function(data){
                $(data.markup)
                    .find('input[name=nonce]').val(nonce).end()
                    .hide().prependTo('#questions').slideDown('slow');
            },
            vote: function(data){
                var question  = $('#question-'+data.question_id),
                    cur_count = question.data('count'),
                    new_count = cur_count+1;

                // Updates the count
                question
                    .attr('data-count', new_count)
                    .data('count', new_count)
                    .addClass('new-vote');

                setTimeout(1000, function(){
                    question.removeClass('new-vote');
                });
            }
        };

})(jQuery);

测试动画

要查看实际效果,请使用两个浏览器加入一个房间(确保没有一个浏览器是演示者),并将它们并排放置,以便您可以同时看到两个浏览器。

在一个浏览器中,问一个新问题;当ask事件被触发时,它将被动态添加到另一个浏览器窗口。

在另一个浏览器中,对新问题进行投票。提交投票时,观看第一个浏览器:它将在动画中运行一遍,然后返回正常状态。很难在静态图像中演示这一点,但是图 10-8 显示了正在进行的动画。

9781430246206_Fig10-08.jpg

图 10-8。当使用左边的浏览器对首要问题投票时,动画会在右边的浏览器中触发

用动画和重新排序回答问题

当一个问题被标记为已回答时,它应该淡出,然后从列表中移除(通过向上的动画幻灯片)并重新附加到底部(也通过动画幻灯片),以便为未回答的问题腾出空间。

将以下粗体显示的代码添加到init.js中,使其发生:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){ question.ask(data); });
    channel.bind('vote',   function(data){ question.vote(data); });
    channel.bind('answer', function(data){ question.answer(data); });

    var nonce = $('input[name=nonce]:eq(0)').val(),
        room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        },
        question = {
            ask: function(data){
                $(data.markup)
                    .find('input[name=nonce]').val(nonce).end()
                    .hide().prependTo('#questions').slideDown('slow');
            },
            vote: function(data){
                var question  = $('#question-'+data.question_id),
                    cur_count = question.data('count'),
                    new_count = cur_count+1;

                // Updates the count
                question
                    .attr('data-count', new_count)
                    .data('count', new_count)
                    .addClass('new-vote');

                setTimeout(1000, function(){
                    question.removeClass('new-vote');
                });
            },
            answer: function(data){
                var question = $("#question-"+data.question_id),
                    detach_me = function() {
                        question
                            .detach()
                            .appendTo('#questions')
                            .slideDown(500);
                    }

                question
                    .addClass('answered')
                    .delay(1000)
                    .slideUp(500, detach_me);
            }
        };

})(jQuery);

测试答题

要查看标记为已回答的问题在与会者看来是什么样子,请打开两个浏览器并将窗口并排放置,以便您可以同时看到两个窗口。在一个浏览器中,创建一个新房间;在另一个房间里,问一个问题。

问题将显示在创建房间的浏览器上,此时您可以将问题标记为已回答。在与会者视图中,问题将淡化、消失,然后以“已回答”状态重新附加(参见图 10-9 )。因为房间里只有一个问题,所以不会演示如何将问题移到列表的底部,但是如果您愿意,您可以进行自己的实验来查看实际效果。

9781430246206_Fig10-09.jpg

图 10-9。一个在与会者和演示者视图中都被标记为已回答的问题

摘要

在这一章中,你确切地了解了实现实时事件是多么的快捷和容易,以及将它们集成到你的应用中的效果。

至此,应用已经完成,您已经准备好开始构建自己令人惊叹的实时应用了。请在 Twitter 上找到你的作者——@ jlengstorf 和@ leggetter——并分享你的创作。

朋友,欢迎来到网页设计的未来。

十一、附录 A:OAuth

在本附录中,我们将使用用户现有的社交媒体帐户,在您的 web 应用中对用户进行身份验证,从而消除在网站上使用用户名和密码组合的需要。

为此,我们将使用 OAuth 协议。

OAuth 是什么?

根据 OAuth 主页上的说法, OAuth 是“一个开放的协议,允许桌面和 web 应用以简单和标准的方式进行安全的 API 授权。” 1

这在很大程度上意味着,OAuth 为你的应用提供了一种方式来访问用户的其他帐户,如脸书,而不需要在你的应用中输入用户的脸书密码。

更深入地说,OAuth 为开发人员提供了一个标准化的协议,让他们向服务提供商注册,获取凭证,并使用这些凭证让他们的应用代表用户向服务提供商请求权限。

OAuth 的历史

OAuth 1.0 于 2007 年完成,解决了随着脸书和 Twitter 等网站的流行而困扰开发者的一个问题。这些网站如何在不要求用户向其他应用提供密码的情况下进行互动?

Twitter 的早期采用者被新的应用、工具和服务淹没,这些应用、工具和服务增强了 Twitter,自动化了推文,将具有相似兴趣的人联系起来,并展示了使用 Twitter 平台的无数其他有趣的方式。不幸的是,要使用这些应用,用户最初需要输入他们的 Twitter 用户名和密码,以授予应用访问帐户的权限。这种访问是不受限制的,所以用户只是相信这些应用开发人员会负责任,并希望得到最好的结果。

显然,这不是一个可持续的模式。

在 OAuth 背后的团队研究了许多现有的专有解决方案(如 Google AuthSub、AOL OpenAuth 和 Amazon Web Services API 等服务)并将最佳实践结合到一个开放协议中之后,OAuth 成为了一个替代的身份验证协议,该协议易于任何服务使用和任何开发人员实现。

OAuth 目前正在起草 OAuth 2.0 草案,包括脸书在内的一些服务提供商已经实施了该草案。

OAuth 如何工作

在我们讨论正在发生的事情之前,让我们先来看看现实世界中的 OAuth 工作流:社交照片共享网站 Flickr 是 Yahoo!但它也允许用户使用其脸书或谷歌账户登录服务。

最有可能的是,这个工作流程是您以前见过的,甚至可能是您在多个场合使用过的。在用户方面,它极其简单,这也是它吸引人的一部分。用户点击登录按钮,选择使用一个支持 OAuth 的现有帐户登录(参见图 A-1 ),然后使用所选服务(在本例中为脸书)确认请求应用有权访问所请求的数据(图 A-2 )。之后,用户登录。

9781430246206_AppA-01.jpg

图 A-1。除了基于雅虎的账户系统,Flickr 主页还允许用户登录谷歌或脸书

9781430246206_AppA-02.jpg

图 A-2。点击脸书登录后,雅虎向脸书请求权限,您可以选择批准或取消

image 注意如果你看脸书登录对话框的右下方,列出了请求的权限,供用户在批准前查看。

从用户的角度来看,这是三次快速点击。从开发人员的角度来看,它稍微复杂一些(尽管它仍然比自定义的帐户注册和登录系统更容易实现)。

OAuth 开发人员工作流程

在应用中实现 OAuth 的开发人员工作流程相当简单:

  1. 您的应用将用户发送到授权端点,或统一资源指示器(URI),通过它,API 公开一个动作,以及它的凭证、它自己的授权端点、它需要什么权限和一个安全令牌。
  2. 服务的授权端点要求用户确认您的应用是否被允许代表用户访问 API。
  3. 假设用户授予访问权限,服务会将用户重定向回您的应用的授权端点,以及步骤 1 中的授权代码和安全令牌。
  4. 您的应用通过发送在步骤 3 中收到的授权代码,再加上其凭据和您的应用的授权端点,从服务的令牌端点请求访问令牌。
  5. 该服务对您的应用进行身份验证,检查授权代码,并发回一个访问令牌,该令牌可用于通过服务的 API 访问用户的数据。

以一个真实的服务为例,让我们看看脸书的 OAuth 2.0 2 实现使用的实际端点。

建立登录链接

应用请求访问用户数据的第一步是将用户定向到服务提供商的授权端点。为脸书做这件事的端点 URI 是https://www.facebook.com/dialog/oauth,应用必须随请求一起发送它的client_idredirect_uriscopestate

假设用户在获得应用的访问权限后应该被重定向到http://app.example.org/login.php,应用的登录链接可能如下所示:

https://www.facebook.com/dialog/oauth?client_id=YOUR_APP_ID& redirect_uri=http%3a%2f%2fapp.example.org%2flogin.php&scope=email&state= 73ef0836082f31

image 注意client_id的值是你注册 app 后脸书提供的唯一值。当前值YOUR_APP_ID是一个占位符,应该替换为您自己的应用凭证。暂时不要担心注册你的应用;我们将在本章后面的 A-1 练习中完成这个过程。

这个例子使用GET方法向端点发送参数。为了更容易识别,它们以粗体显示。

  • client_id是脸书在应用注册后为其生成的公共标识符。它让脸书知道谁请求访问。
  • redirect_uri是授权应用后用户应被重定向到的 URI。这个 URI 是你的应用的授权端点,应用将在这里处理脸书发送的数据。
  • scope是从脸书请求的权限的逗号分隔列表。在脸书的开发者文档中有一个完整的列表,前面提到过,但是在本书中唯一使用的是email
  • state是为防止跨站点请求伪造而生成的任意字符串。这在技术上是可选的,但是应该使用。

从用户处获得授权

在用户点击前一部分生成的登录链接后,她将看到脸书的授权对话框。这将向她显示所请求的权限,并为她提供确认或拒绝向您的应用授予这些权限的选项。

假设她批准了授权请求,脸书会将她重定向回您的应用的授权端点,这是在登录链接的redirect_uri参数中传递的。脸书将从登录链接发回一个codestate的值,如下所示:

http://app.example.org/login.php?code=CODE_GENERATED_BY_FACEBOOK&state=73ef0836082f31

image 注意code的值将是一个由脸书生成的长字符串,对每个请求都是唯一的。CODE_GENERATED_BY_FACEBOOK是占位符。

请求访问令牌

有了code的值,你的应用可以向脸书请求一个访问令牌。这是通过发送code、您的应用在client_idclient_secret中的凭证以及您的应用在redirect_uri中的授权端点来完成的。

该 URL 将类似于以下内容:

https://graph.facebook.com/oauth/access_token?client_id=YOUR_APP_ID&redirect_uri=http%3a%2f%2fapp.example.org%2flogin.php&client_secret=YOUR_APP_SECRET&code=CODE_GENERATED_BY_FACEBOOK

image client_secret的值是脸书在你的 app 注册后提供给你的另一部分凭证。值YOUR_APP_SECRET是一个占位符,应替换为您的应用凭证。

假设所有需要的参数都是正确有效的,脸书将在access_token返回一个访问令牌,以及在expires中指示该令牌的有效时间(以秒为单位)。

access_token=USER_ACCESS_TOKEN&expires=NUMBER_OF_SECONDS_UNTIL_TOKEN_EXPIRES

image access_token的值是脸书生成的唯一值。expires的值将是一个整数。当前值USER_ACCESS_TOKENNUMBER_OF_SECONDS_UNTIL_TOKEN_EXPIRES是占位符。

如何处理访问令牌

使用访问令牌,您的应用现在可以从脸书 API 请求用户信息。因为除了基本的用户信息之外,它只请求电子邮件,你的应用将不能查看用户的信息流,代表他们发帖,或者做任何高级的事情。它为您的应用提供了身份验证和一点个人资料个性化所需的一切。

要加载用户信息,只需使用脸书的一个 API 端点(在本例中,我们从 Graph API 加载基本用户信息)并传递 access_token 中的访问令牌:

https://graph.facebook.com/me?access_token=USER_ACCESS_TOKEN

用有效的访问令牌加载它将生成一个 JSON 编码的输出,如下所示:

{
   "id": "1468448880",
   "name": "Jason Lengstorf",
   "first_name": "Jason",
   "last_name": "Lengstorf",
   "link": "https://www.facebook.com/jlengstorf",
   "username": "jlengstorf",
   "hometown": {
      "id": "109281049091287",
      "name": "Whitefish, Montana"
   },
   "location": {
      "id": "112548152092705",
      "name": "Portland, Oregon"
   },
   "bio": " I\u2019m a turbogeek from Portland. I design and develop websites, and sometimes I draw pictures of stuff.\n\nI\u2019ve written two books (check 'em out here:http://cptr.me/LP9YAm), and I\u2019ve written articles on development and design for Nettuts, CSS Tricks, and Smashing Magazine, among others.",
   "quotes": "That dog'll hunt.",
   "work": [
      {
         "employer": {
            "id": "169249483097082",
            "name": "Copter Labs"
         },
         "position": {
            "id": "137221592980321",
            "name": "Developer"
         },
         "description": "Making the web a better-looking place.",
         "start_date": "2010-12"
      }

   ],

   "education": [
      {
         "school": {
            "id": "107993102567513",
            "name": "Whitefish High School"
         },
         "year": {
            "id": "194603703904595",
            "name": "2003"
         },
         "type": "High School"
      }
   ],
   "gender": "male",
   "email": "jason\u0040lengstorf.com",
   "timezone": -7,
   "locale": "en_US",
   "languages": [
      {
         "id": "113301478683221",
         "name": "American English"
      }
   ],
   "verified": true,
   "updated_time": "2012-06-29T21:53:51+0000"
}

这些数据可以在您的应用中用来定制用户体验,识别他们所做的任何操作,以及其他需要用户信息的功能。

为什么 OAuth 比构建登录系统更好

在许多情况下,使用 OAuth 对用户进行身份验证是解决“用户帐户”问题的更好方法。创建新帐户对开发者和用户来说都是一件痛苦的事情,所以当应用没有明确的需求来密切控制自己的注册过程时,有很多很好的理由使用 OAuth,而不是实现特定于您的应用的东西。一些好处包括:

  • 用户需要处理的帐户注册和密码少了一个,这降低了潜在新用户的准入门槛:开始意味着三次点击,而不是填写表格,检查他们的确认电子邮件,然后登录。
  • 该应用可以访问基本的用户信息,而无需用户在授权之外进行任何额外的输入或操作。
  • 该应用可以访问服务提供商的 API 及其优势(如在权限允许的情况下,在脸书上共享用户的应用活动)。
  • 它消除了构建复杂的定制登录系统的需要,节省了维护和开发时间。

练习 A-1:使用 OAUTH 和 FACEBOOK 构建一个简单的登录系统

让我们通过构建一个非常简单的应用来使用 OAuth,这个应用只允许用户使用他们的脸书帐户登录。

这个应用将放弃 CSS 样式和任何非必要的标记,因为需要合理数量的 PHP 来实现所需的功能。您将构建一个抽象的 PHP 类来定义基本的 OAuth 2.0 工作流,然后用一个定义端点、凭证和 API 交互方法的特定于脸书的类来扩展该类。

这个应用将由五个文件组成:两个 PHP 类、一个配置文件、一个登录文件和应用的主页。

第一步:在脸书注册你的应用

要向脸书注册您的应用,请导航至https://developers.facebook.com/apps。进入后,点击屏幕右上角的创建新应用按钮。一个模态窗口将会出现,要求你命名你的应用(见图 A-3 )。

9781430246206_AppA-03.jpg

图 A-3。向脸书注册新应用的模式对话框

接下来,你会被带到应用的主屏幕。在内部,选中复选框以表明该应用将允许登录网站,并且它也将是一个移动应用(参见图 A-4 )。

9781430246206_AppA-04.jpg

图 A-4。脸书上的应用主屏幕

保存这些更改,应用就注册了。在页面顶部记下应用 ID 和应用密码;稍后将使用它们来获得授权。

步骤 2:创建一个 OAuth 基类

第一步是创建处理所有通用 OAuth 2.0 工作流的抽象类。使这个类成为抽象类的原因是,如果没有一个服务提供者,它就不能工作,请求应该向服务提供者发出。将该类与脸书特定的类分开的原因是,如果该应用要添加第二个服务提供商作为登录选项,这将防止重复代码。

在 web root 中,创建一个名为includes的文件夹,并在其中创建一个名为class.rwa_oauth.inc.php的新文件。首先,添加一些基本的检查并声明这个类:

<?php

// Makes sure cURL is loaded
if (!extension_loaded('curl')) {
    throw new Exception('OAuth requires the cURL PHP extension.');
}

// Makes sure the session is started
if (!session_id()) {
    session_start();
}

/**
* An abstract class for handling the basics of OAuth 2.0 authorization
*
* @author Jason Lengstorf
* @copyright 2012 Jason Lengstorf
*/
abstract class RWA_OAuth
{

}

因为使用 OAuth 需要应用请求外部 URIs 才能正常工作,所以需要使用cURL扩展。这个脚本使用extension_loaded()来验证cURL支持是否可用,如果不可用,则使用throw来验证Exception

image 注意缺少cURL、和 4 的支持是可能的,但这不在本练习的范围之内。

接下来,它检查活动会话 ID,如果没有找到,就启动一个会话。

这个类被称为RWA_OAuth(可能还有其他类被称为“OAuth ”,所以使用前缀RWA_来防止类命名冲突)。它是抽象的,这意味着它不能被直接实例化;换句话说,另一个类必须在它的方法和属性被访问之前扩展RWA_OAuth

类属性

声明了类之后,我们现在可以定义所有的类属性:

abstract class RWA_OAuth
{

  /**
  * The service's auth endpoint
     * @var string
     */
    public $service_auth_endpoint,

    /**
     * The service's token endpoint
     * @var string
     */
           $service_token_endpoint,

    /**
     * The scope, or permissions, required for the app to work properly
     * @var string
     */
           $scope,

    /**
     * The service-generated client ID
     * @var string
     */
           $client_id,

    /**
     * The service-generated client secret
     * @var string
     */
           $client_secret,

    /**
     * The app's authorization endpoint
     * @var string
     */
           $client_auth_endpoint,

    /**
     * The user's account ID as loaded from the service
     * @var string
     */
           $id,

    /**
     * The user's username as loaded from the service
     * @var string
     */
           $username,

    /**
     * The user's name as loaded from the service
     * @var string
     */
           $name,

    /**
     * The user's email as loaded from the service
     * @var string
     */
           $email,
    /**
     * Generated HTML markup to display the user's profile image
     * @var string
     */
           $profile_image;

    /**
     * The current logged in state
     * @var bool
     */
    protected $logged_in    = FALSE,

    /**
     * The user access token or FALSE if none is set
     * @var mixed
     */
              $access_token = FALSE;

}

image 为了节省空间,只关注相关的代码,类定义之外的代码将被省略,类内与当前例子不相关的代码将被折叠起来,省略注释。新代码将以粗体显示。

  • $service_auth_endpoint$service_token_endpoint$client_auth_endpoint将存储指向用户授权所需的每个端点的 URIs。
  • $scope存储应用请求的权限。
  • $client_id$client_secret存储您的应用凭证(通过向服务提供商注册您的应用获得)。
  • $id$username$name$email存储关于用户的数据。
  • $profile_image存储 HTML 标记以显示用户的个人资料图像。
  • $logged_in存储一个布尔值,指示用户当前是否登录。
  • $access_token存储授权成功后从服务提供商处获取的访问令牌。

接下来,通过定义所有方法名来布置类的框架:

abstract class RWA_OAuth
{

    public $service_auth_endpoint, $service_token_endpoint, $scope,
           $client_id, $client_secret, $client_auth_endpoint,
           $id, $username, $name, $email, $profile_image;

    protected $logged_in    = FALSE,
              $access_token = FALSE;

    /**
     * Checks for a login or logout attempt
     *
     * @return void
     */
    public function __construct(  )
    {

    }

    /**
     * Checks a login attempt for validity
     *
     * @return void
     */
    public function check_login(  )
    {

    }

    /**
     * Returns the current logged in state
     *
     * @return bool The current logged in state
     */
    public function is_logged_in(  )
    {

    }

    /**
     * Processes a logout attempt
     *
     * @return void
     */
    public function logout(  )
    {

    }

    /**
     * Returns the URI with which a authorization can be requested
     *
     * @return string   The authorization endpoint URI with params
     */
    public function get_login_uri(  )
    {

    }

    /**
     * Returns the URI with which an access token can be generated
     *
     * @return string   The access token endpoint URI with params
     */
    protected function get_access_token_uri(  )
    {

    }

    /**
     * Saves the access token in the session and as a class property
     *
     * @return bool TRUE on success, FALSE on failure
     */
    protected function save_access_token(  )
    {

    }

    /**
     * Makes a request using cURL and returns the resulting data
     *
     * @param string $uri   The URI to be requested
     * @return string       The data returned by the requested URI
     */
    protected function request_uri(  )
    {

    }

    /**
     * Loads the basic user data, including name and email
     *
     * This method will be different for each OAuth authorized service,
     * so it will need to be defined in the child class for the service.
     */
    abstract protected function load_user_data();

    /**
     * Generates markup to display the user's profile image
     *
     * This method will be different for each OAuth authorized service,
     * so it will need to be defined in the child class for the service.
     */
    abstract protected function load_user_profile_image();

}

这些方法中的每一个都将在定义时详细介绍。简而言之,这些是完成本章前面“OAuth 开发人员工作流”一节中描述的 OAuth 工作流所需的所有方法。

获取登录 uri() &获取访问令牌 uri()

定义了类框架之后,让我们从授权和令牌生成所需的端点开始,定义get_login_uri()get_access_token_uri():

abstract class RWA_OAuth
{

    public $service_auth_endpoint, $service_token_endpoint, $scope,
           $client_id, $client_secret, $client_auth_endpoint,
           $id, $username, $name, $email, $profile_image;

    protected $logged_in    = FALSE,
              $access_token = FALSE;

    public function __construct(  ) {...}

    public function check_login(  ) {...}

    public function is_logged_in(  ) {...}

    public function logout(  ) {...}

    public function get_login_uri(  )
    {
        $state = uniqid(mt_rand(10000,99999), TRUE);
        $_SESSION['state'] = $state;
        return $this->service_auth_endpoint
             . '?client_id='    . $this->client_id
             . '&redirect_uri=' . urlencode($this->client_auth_endpoint)
             . '&scope='        . $this->scope
             . '&state='        . $state;
    }

    protected function get_access_token_uri($code=NULL)
    {
        return $this->service_token_endpoint
             . '?client_id='     . $this->client_id
             . '&redirect_uri='  . urlencode($this->client_auth_endpoint)
             . '&client_secret=' . $this->client_secret
             . '&code='          . $code;
    }
    protected function save_access_token(  ) {...}

    protected function request_uri(  ) {...}

    abstract protected function load_user_data();

    abstract protected function load_user_profile_image();

}

get_login_uri()方法做的第一件事是生成一个惟一的字符串作为state参数传递,它将用于验证授权请求是真实的。这也存储在进程中,供以后参考。

之后,我们将$service_auth_endpoint与所需的参数连接起来。为了避免编码问题,首先通过urlencode()运行$client_auth_endpoint值。

组装完成后,授权请求或登录 URI 将被返回。

get_access_token_uri()遵循相同的过程,有两个小的不同:1)没有使用state参数,2)增加了client_secret参数。它还接受从服务提供商发回的参数$code。我们将在下一节中介绍如何获取该值。

保存 _ 访问 _ 令牌()和请求 _uri()

为了加载和保存生成的令牌,让我们定义save_access_token()request_uri():

abstract class RWA_OAuth
{

    public $service_auth_endpoint, $service_token_endpoint, $scope,
           $client_id, $client_secret, $client_auth_endpoint,
           $id, $username, $name, $email, $profile_image;

    protected $logged_in    = FALSE,
              $access_token = FALSE;

    public function __construct(  ) {...}

    public function check_login(  ) {...}

    public function is_logged_in(  ) {...}

    public function logout(  ) {...}

    public function get_login_uri(  ) {...}

    protected function get_access_token_uri(  ) {...}

    protected function save_access_token(  )
    {
        $token_uri = $this->get_access_token_uri($_GET['code']);
        $response = $this->request_uri($token_uri);

        // Parse the response
        $params = array();
        parse_str($response, $params);
        if (isset($params['access_token'])) {
            $_SESSION['access_token'] = $params['access_token'];
            $this->access_token       = $params['access_token'];
            $this->logged_in          = TRUE;
            return TRUE;
        }

        return FALSE;
    }

    protected function request_uri($uri)
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $uri);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4);

        return curl_exec($ch);
    }

    abstract protected function load_user_data();

    abstract protected function load_user_profile_image();

}

首先,我们来看看request_uri()。这个方法接受一个参数$uri,它是请求的 URI。然后,它初始化cURL,将请求的 URI 设置为$uri,告诉cURL返回请求的输出,并将超时设置为任意但合理的值,即 4 秒。

接下来,在save_access_token()中,使用get_access_token_uri()code的值(假定已经在查询字符串中传递)将令牌请求 URI 加载到$token_uri中。然后来自$token_uri的响应用request_uri()加载后存储在$response中。

除非出现错误,否则$response中的值将包含查询字符串格式的访问令牌。使用$params数组和parse_str(),查询字符串被分解成一个关联数组,我们在其中检查access_token。如果它被设置,它将被存储在对象的属性和会话中,$logged_in被设置为TRUE,该方法返回TRUE;如果没有,该方法返回FALSE以指示失败。

其余的类方法

让我们通过定义剩余的四个方法来结束RWA_OAuth类:

abstract class RWA_OAuth
{

    public $service_auth_endpoint, $service_token_endpoint, $scope,
           $client_id, $client_secret, $client_auth_endpoint,
           $id, $username, $name, $email, $profile_image;

    protected $logged_in    = FALSE,
              $access_token = FALSE;

    public function __construct(  )
    {
        if (isset($_GET['logout']) && $_GET['logout']==1) {
            $this->logout();
            header('Location: ' . $this->client_auth_endpoint);
            exit;
        }

        $this->check_login();
    }

    public function check_login(  )
    {
        if (isset($_GET['state']) && isset($_GET['code'])) {
            if ($_GET['state']===$_SESSION['state']) {
                $this->save_access_token();
                $this->load_user_data();
                $this->load_user_profile_image();
            } else {
                throw new Exception("States don't match.");
            }
        } elseif (!$this->logged_in && !$this->access_token) {
            if (isset($_SESSION['access_token'])) {
                $this->access_token = $_SESSION['access_token'];
                $this->logged_in = TRUE;
                $this->load_user_data();
                $this->load_user_profile_image();
            }
        }
    }

    public function is_logged_in(  )
    {
        return $this->logged_in;
    }

    public function logout(  )
    {
        $this->logged_in = FALSE;
        $this->access_token = FALSE;
        unset($_SESSION['access_token']);
        session_regenerate_id();
        session_destroy();
    }

    public function get_login_uri(  ) {...}

    protected function get_access_token_uri(  ) {...}

    protected function save_access_token(  ) {...}

    protected function request_uri(  ) {...}

    abstract protected function load_user_data();

    abstract protected function load_user_profile_image();

}

方法 中的is_logged_in()是最简单的,简单地返回$logged_in的值。

logout() 也非常简单:它将$logged_in$access_token设置为FALSE,确保访问令牌从会话中删除,然后彻底销毁会话。

check_login() 在查询字符串中查找statecode,如果它们存在,它检查state是否与会话中存储的匹配。如果匹配,则运行save_access_token()方法;然后调用类中的两个抽象方法(load_user_data()load_user_profile_image(),它们将在一个子类中定义)。如果它们不匹配,就会抛出一个Exception

如果用户未登录,但会话中存在访问令牌,脚本将在对象中保存访问令牌,将$logged_in设置为TRUE,并加载用户数据和配置文件映像。

对象的构造函数首先检查注销尝试,这是通过查询字符串($_GET['logout'])发送的。除此之外,它运行check_login()

步骤 3:构建脸书·欧特子类

有了RWA_OAuth类,我们就可以进行特定于服务的 OAuth 实现了。在这个例子中,我们将使用脸书。

includes文件夹中,创建名为class.rwa_facebook.inc.php的新文件。在内部放置以下代码:

<?php

// Makes sure JSON can be parsed
if (!extension_loaded('json')) {
    throw new Exception('OAuth requires the JSON PHP extension.');
}

/**
* An RWA_OAuth extension for Facebook's OAuth 2.0 implementation
*
* @author Jason Lengstorf
* @copyright 2012 Jason Lengstorf
*/
class RWA_Facebook extends RWA_OAuth
{

    public $service_auth_endpoint
                = 'https://www.facebook.com/dialog/oauth',
           $service_token_endpoint
                = 'https://graph.facebook.com/oauth/access_token';

    /**
     * Sets defaults, calls the parent constructor to check login/logout
     *
     * @param array $config Configuration parameters for Facebook OAuth
     * @return void
     */
    public function __construct( $config=array() )
    {
        /*
         * In order to use OAuth, the client_id, client_secret, and
         * client_auth_endpoint must be set, so execution fails here if
         * they aren't provided in the config array
         */
        if (   !isset($config['client_id'])
            || !isset($config['client_secret'])
            || !isset($config['client_auth_endpoint'])
        ) {
            throw new Exception('Required config data was not set.');
        }

        $this->client_id            = $config['client_id'];
        $this->client_secret        = $config['client_secret'];
        $this->client_auth_endpoint = $config['client_auth_endpoint'];

        /*
         * Adding scope is optional, so if it's in the config, this sets
         * the class property for authorization requests
         */
        if (isset($config['scope'])) {
            $this->scope = $config['scope'];
        }

        // Calls the OAuth constructor to check login/logout
        parent::__construct();
    }

    /**
     * Loads the user's data from the Facebook Graph API
     *
     * @return void
     */
    protected function load_user_data(  )
    {
        $graph_uri = 'https://graph.facebook.com/me?'
                   . 'access_token=' . $this->access_token;
        $response = $this->request_uri($graph_uri);

        // Decode the response and store the values in the object
        $user = json_decode($response);
        $this->id       = $user->id;
        $this->name     = $user->name;
        $this->username = $user->username;
        $this->email    = $user->email;
    }

    /**
     * Generates HTML markup to display the user's Facebook profile image
     *
     * @return void
     */
    protected function load_user_profile_image(  )
    {
        $image_path = 'https://graph.facebook.com/' . $this->id
                    . '/picture';
        $this->profile_image = '<img src="' . $image_path . '" '
                             . 'alt="' . $this->name . '" />';
    }
}

首先,因为脸书以 JSON 编码的格式发回数据,如果 PHP 中没有加载json扩展,脚本将抛出一个Exception

之后,声明RWA_Facebook类来扩展RWA_OAuth。在里面,两个属性被重新声明——$service_auth_endpoint$service_token_endpoint——构造函数被重新定义,来自RWA_OAuth的两个抽象方法被声明。

$service_auth_endpoint$service_token_endpoint是特定于脸书的,因此它们可以被硬编码到类中。

其他属性值——即$client_auth_endpoint$client_id$client_secret,以及可选的$scope ((如果我们需要的不仅仅是基本的应用,我们并不需要)——是通过构造函数设置的。端点和应用凭证是必需的,如果它们没有设置,抛出一个Exception,然后该方法在$config数组中检查scope。然后运行父构造函数来检查登录和注销尝试。

第四步:创建一个脸书配置文件

RWA_Facebook类将被加载到应用的主页和应用的验证端点,因此脸书配置细节将被抽象到一个单独的文件中。在includes文件夹中,创建一个名为fb_config.inc.php的新文件,并将以下内容放入其中:

<?php

$fb_config = array(
    'client_id' => '404595786243388',
    'client_secret' => 'da2c599d9662ee744700dbfa483a154e',
    'client_auth_endpoint'
            => 'http://rwa.cptr.us/exercises/05/01/login.php',
    'scope' => 'email',
);

这个文件非常简单:它定义了一个数组,存储在$fb_config中,当它被实例化时将被传递给RWA_Facebook类的构造函数。

image 注意确保用你的应用凭证替换client_idclient_secret,因为它们是特定于域的,并且在没有设置它们的域上无效。

第五步:创建应用的认证端点

现在,所有的类和配置文件都已就绪,您可以开始构建应用的实际页面了。首先在 web 根目录下创建文件login.php。在内部放置以下内容:

<?php

// Set error reporting to keep the code clean
error_reporting(E_ALL^E_STRICT);
ini_set('display_errors', 1);

// Loads the Facebook class
require_once 'includes/class.rwa_oauth.inc.php';
require_once 'includes/class.rwa_facebook.inc.php';

// Loads the $fb_config array
require_once 'includes/fb_config.inc.php';

$facebook = new RWA_Facebook($fb_config);

// If the user is logged in, send them to the home page
if ($facebook->is_logged_in()) {
    header("Location: ./");
    exit;
}

?>
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf8" />
        <title>Please Log In</title>
    </head>
    <body>
        <h2>Please Log In</h2>
        <p>
            Before you can use this sweet app, you have to log in.
        </p>
        <p>
            <a href="<?php echo $facebook->get_login_uri(); ?>">
                Log In with Facebook
            </a>
        </p>
    </body>
</html>

该文件首先将错误报告设置得尽可能高,以确保代码非常干净,然后包括 OAuth 类和脸书配置文件。

加载必要的文件后,一个新的RWA_Facebook对象被实例化并存储在$facebook中。然后,脚本通过运行is_logged_in()方法检查用户是否登录,如果是,则重定向到主页。

对于已注销的用户,会显示一个简单的 HTML 页面,要求用户登录。使用get_login_uri()方法生成一个登录 URI。

第六步:创建应用主页

最后创建的文件是应用的主页。在 web 根目录下创建一个名为 index.php 的文件,然后添加以下代码:

<?php

// Set error reporting to keep the code clean
error_reporting(E_ALL^E_STRICT);
ini_set('display_errors', 1);

// Loads the Facebook class
require_once 'includes/class.rwa_oauth.inc.php';
require_once 'includes/class.rwa_facebook.inc.php';

// Loads the $fb_config array
require_once 'includes/fb_config.inc.php';

$facebook = new RWA_Facebook($fb_config);

// If the user is not logged in, send them to the login page
if (!$facebook->is_logged_in()) {
    header('Location: login.php');
    exit;
}

?>
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf8" />
        <title>Logged In!</title>
    </head>
    <body>
        <h2>You're Logged In</h2>
        <p>
            Welcome to our super-sweet app!
        </p>
        <h3>Your Info</h3>
        <ul>
            <li>
                <?php echo $facebook->profile_image; ?>
            </li>
            <li>
                <strong>Name:</strong>
                <?php echo $facebook->name; ?>
            </li>
            <li>
                <strong>Email:</strong>
                <?php echo $facebook->email; ?>
            </li>
            <li>
                <strong>Username:</strong>
                <?php echo $facebook->username; ?>
            </li>
        </ul>
        <p>

            <a href="?logout=1">Log out</a>
        </p>
    </body>
</html>

login.php一样,这个文件首先打开错误报告,包括必要的文件,并在$facebook中实例化RWA_Facebook对象。它检查用户是否登录,如果没有,就把他送到登录页面。

对于已登录的用户,会显示一个简单的 HTML 页面,其中包含一条快速欢迎消息、他们的信息、他们的个人资料图片以及一个注销按钮。

第七步:测试 App

该应用现已准备好进行测试。将其加载到浏览器中,您应该会看到登录屏幕(参见图 A-5 )。

9781430246206_AppA-05.jpg

图 A-5。应用的登录屏幕

点击登录按钮,你将被带到脸书的授权端点,要求你确认该应用已被授权访问你的信息(见图 A-6 )。

9781430246206_AppA-06.jpg

图 A-6。脸书授权对话框

点击转到应用按钮确认授权,您将被重定向到您的应用,您将在主页上看到您的信息(参见图 A-7 )。

9781430246206_AppA-07.jpg

图 A-7。登录后,应用将显示欢迎信息和您的信息

1

2

3

4

第一部分:熟悉所需技术

构建 web 应用不是一维的练习。现代 web 开发人员将需要利用多种技术来构建满足用户需求的应用。

在本书的这一部分,您将熟悉用于构建第一个实时 web 应用的技术。由于这个项目利用了写作时使用的一些更常见的 web 技术,所以这本书的大部分内容对您来说应该很熟悉,如果您觉得不需要复习就可以跳过。

第二部分:规划 App

既然你已经熟悉了这个应用中将要使用的技术,它们来自哪里,以及它们是如何工作的,你就可以开始计划这个应用了。

本书的这一部分涵盖了应用的规划,从决定选择 web 应用而不是本地应用开始,继续定义应用的功能,最后绘制应用代码的结构和架构。在本书的这一部分结束时,你应该已经计划好了整个应用,只剩下实际的构建了。

第三部分:构建基础

现在你已经准备好了所需的工具、一个线框和一个可靠的攻击计划。在本节中,您将构建应用的基础。

在本节结束时,您将拥有一个功能正常的应用,其前端用于在多种屏幕尺寸上显示信息,后端用于动态存储和检索房间数据和问题信息。

posted @ 2024-08-03 11:22  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报