HTML5-WebSocket-权威指南-全-

HTML5 WebSocket 权威指南(全)

原文:The Definitive Guide to HTML5 WebSocket

协议:CC BY-NC-SA 4.0

一、HTML5 WebSocket 简介

这本书是为任何想学习如何构建实时 web 应用的人准备的。你可能会对自己说,“我已经这样做了!”或者问“那到底是什么意思?”让我们澄清一下:这本书将向您展示如何使用一种革命性的新的、被广泛支持的开放式行业标准技术 webSocket 来构建真正实时的 Web 应用,这种技术可以在您的客户端应用和远程服务器之间通过 Web 实现全双工、双向通信——无需插件!

还在迷茫?几年前我们也是,在我们开始使用 HTML5 WebSocket 之前。在本指南中,我们将解释您需要了解的 WebSocket 知识,以及为什么您现在应该考虑使用 WebSocket。我们将向您展示如何在您的 web 应用中实现 WebSocket 客户端,创建您自己的 WebSocket 服务器,将 WebSocket 与 XMPP 和 STOMP 等高级协议一起使用,保护您的客户端和服务器之间的流量,以及部署您的基于 WebSocket 的应用。最后,我们将解释为什么你现在应该考虑使用 WebSocket。

HTML5 是什么?

首先,让我们检查“HTML5 WebSocket”的“HTML5”部分。如果你已经是 HTML5 的专家,已经阅读过,比如说, Pro HTML5 Programming ,并且已经在开发非常现代和响应迅速的 web 应用,那么请随意跳过这一部分,继续阅读。但是,如果你是 HTML5 的新手,这里有一个快速介绍。

HTML 最初是为互联网上静态的、基于文本的文档共享而设计的。随着时间的推移,由于 web 用户和设计者希望在他们的 HTML 文档中有更多的交互性,他们开始通过添加表单功能和早期的“门户”类型功能来增强这些文档。现在,这些静态文档集合,或者网站,更像是基于富客户机/服务器桌面应用的 web 应用。这些网络应用几乎可以在任何设备上使用:笔记本电脑、智能手机、平板电脑——应有尽有。

HTML5 是设计来使这些富 web 应用的开发更容易、更自然、更符合逻辑,开发者可以设计和构建一次,然后部署到任何地方。HTML5 也使得网络应用更加有用,因为它不再需要插件。有了 HTML5,你现在可以使用像<header>这样的语义标记语言来代替<div class="header">.多媒体也更容易编码,通过使用像<audio><video>这样的标签来引入和分配适当的媒体类型。此外,由于具有语义,HTML5 更容易访问,因为屏幕阅读器可以更容易地读取它的标签。

HTML5 是一个总括术语,涵盖了 web 技术中发生的大量改进和变化,包括从您在网页上使用的标记到 CSS3 样式、离线和存储、多媒体、连接性等等。图 1-1 显示了不同的 HTML5 特性区域。

9781430247401_Fig01-01.jpg

图 1-1 。HTML5 功能区(W3C,2011)

有很多资源深入研究 HTML5 的这些领域。在本书中,我们关注连接性领域,即 WebSocket API 和协议。让我们来看看 HTML5 连接的历史。

HTML5 连接性

HTML5 的连接领域包括 WebSocket、服务器发送事件和跨文档消息传递等技术。这些 API 包含在 HTML5 规范中,有助于简化浏览器限制阻止 web 应用开发人员创建他们想要的丰富行为或 web 应用开发变得过于复杂的一些领域。HTML5 简化的一个例子是跨文档消息传递。

在 HTML5 之前,由于安全原因,浏览器窗口和框架之间的通信受到限制。然而,随着 web 应用开始将来自不同网站的内容和应用集合在一起,这些应用之间的相互通信变得很有必要。为了解决这个问题,标准团体和主要浏览器厂商同意支持跨文档消息传递,这使得跨浏览器窗口、选项卡和 iFrames 的跨来源通信变得安全。跨文档消息传递将 postMessage API 定义为发送和接收消息的标准方式。有许多使用来自不同主机和域的内容的用例,例如地图、聊天和社交网络,以便在 web 浏览器内部进行通信。跨文档消息传递提供了 JavaScript 上下文之间的异步消息传递。

跨文档消息传递的 HTML5 规范还通过引入由方案、主机和端口定义的的概念来澄清和细化域安全性。基本上,当且仅当两个 URIs 具有相同的方案、主机和端口时,它们才被认为来自相同的来源。原点值中不考虑路径。

以下示例显示了不匹配的方案、主机和端口(以及不同的来源):

  • https://www.example.com and http://www.example.com
  • http://www.example.com and http://example.com
  • http://example.com:8080 and http://example.com:8081

下面的例子是同源的 URL:http://www.example.com/page1.htmlhttp://www.example.com/page2.html

跨文档消息传递通过允许消息在不同来源之间交换,克服了同源限制。当您发送邮件时,发件人会指定收件人的来源,当您收到邮件时,发件人的来源会包含在邮件中。消息的来源是由浏览器提供的,不能被欺骗。在接收方,您可以决定处理哪些消息,忽略哪些消息。您还可以保留一个“白名单”,只处理来自来源可信的文档的消息。

*跨文档消息传递是一个很好的例子,说明 HTML5 规范用一个非常强大的 API 简化了 web 应用之间的通信。但是,它的重点仅限于跨窗口、选项卡和 iFrames 的通信。它没有解决在协议通信中变得势不可挡的复杂性,这就把我们带到了 WebSocket。

HTML5 规范的主要作者伊恩·希克森在 HTML5 规范的通信部分增加了我们现在所说的 WebSocket。WebSocket 最初名为 TCPConnection ,现在已经演变成了自己独立的规范。虽然 WebSocket 现在不属于 HTML5 的范畴,但它对于在现代(基于 HTML5 的)web 应用中实现实时连接非常重要。WebSocket 也经常被讨论为 HTML5 的连接领域的一部分。那么,为什么 WebSocket 在今天的 Web 中有意义呢?让我们首先来看看协议通信非常重要的较老的 HTTP 架构。

旧 HTTP 架构概述

为了理解 WebSocket 的重要性,让我们先来看一下旧的架构,特别是那些使用 HTTP 的架构。

HTTP 101 (或者说,HTTP/1.0 和 HTTP/1.1)

在旧的架构中,连接是由 HTTP/1.0 和 HTTP/1.1 处理的。HTTP 是客户端/服务器模型中的请求-响应协议,其中客户端(通常是 web 浏览器)向服务器提交 HTTP 请求,服务器使用请求的资源(如 HTML 页面)以及关于页面的附加信息进行响应。HTTP 也是为获取文档而设计的;HTTP/1.0 足以满足来自服务器的单个文档请求。然而,随着 Web 的发展超出了简单的文档共享,并开始包含更多的交互性,连接性需要改进,以实现浏览器请求和服务器响应之间更快的响应时间。

在 HTTP/1.0 中,对服务器的每个请求都要为建立一个单独的连接,至少可以说,这样做的扩展性不好。HTTP 的下一个版本 HTTP/1.1 增加了可重用的连接。随着可重用连接的引入,浏览器可以初始化到 web 服务器的连接来检索 HTML 页面,然后重用相同的连接来检索图像、脚本等资源。HTTP/1.1 通过减少从客户端到服务器的连接数量,减少了请求之间的延迟。

HTTP 是无状态的,这意味着它将每个请求视为唯一和独立的。无状态协议有一些优点:例如,服务器不需要保存关于会话的信息,因此不需要存储这些数据。然而,这也意味着为每个 HTTP 请求和响应发送关于请求的冗余信息。

让我们看一个从客户机到服务器的 HTTP/1.1 请求的例子。清单 1-1 显示了一个包含几个 HTTP 头的完整的 HTTP 请求。

*清单 1-1 。??【HTTP/1.1】客户端到服务器的请求头 *

GET /PollingStock/PollingStock HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.1.5) Gecko/20091102 Firefox/3.5.5
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer:http://localhost:8080/PollingStock/
Cookie: showInheritedConstant=false; showInheritedProtectedConst
ant=false; showInheritedProperty=false; showInheritedProtectedPr
operty=false; showInheritedMethod=false; showInheritedProtectedM
ethod=false; showInheritedEvent=false; showInheritedStyle=false;
showInheritedEffect=false;

清单 1-2 显示了一个从服务器到客户端的 HTTP/1.1 响应的例子。

清单 1-2 。HTTP/1.1 Response Headers 从服务器到客户端

HTTP/1.x 200 OK
X-Powered-By: Servlet/2.5
Server: Sun Java System Application Server 9.1_02
Content-Type: text/html;charset=UTF-8
Content-Length: 321
Date: Wed, 06 Dec 2012 00:32:46 GMT

在清单 1-1 和 1-2 中,总开销是 871 字节的单独头信息(也就是说,没有实际数据)。这两个例子只显示了请求的头部信息,这些信息通过网络在每个方向上传输:从客户机到服务器,以及从服务器到客户机,而不管服务器是否有实际的数据或信息要传递给客户机。

对于 HTTP/1.0 和 HTTP/1.1,效率低下的主要原因如下:

  • HTTP 是为文档共享而设计的,而不是我们在桌面和现在的网络上已经习惯的丰富的交互式应用
  • 客户机和服务器之间的交互越多,HTTP 协议在客户机和服务器之间通信所需的信息量就越大

从本质上来说,HTTP 也是半双工,意思是流量每次单向流动:客户端向服务器发送请求(单向);然后,服务器响应请求(单向)。半双工是非常低效的。想象一次电话交谈,每次你想交流时,你必须按一个按钮,陈述你的信息,然后按另一个按钮来完成它。与此同时,你的对话伙伴必须耐心地等待你结束,按下按钮,然后最终以同样的方式回应。听起来熟悉吗?我们小时候在小范围内使用这种交流方式,我们的军队一直在使用这种方式:这是一种对讲机。虽然对讲机肯定有好处和很大的用途,但它们并不总是最有效的沟通方式。

多年来,工程师们一直在用各种众所周知的方法解决这个问题:轮询、长轮询和 HTTP 流。

绕远路:HTTP 轮询、长轮询和流

通常,当浏览器访问一个网页时,一个 HTTP 请求被发送到承载该网页的服务器。web 服务器确认该请求,并将响应发送回 web 浏览器。在许多情况下,返回的信息,如股票价格、新闻、交通模式、医疗设备读数和天气信息,在浏览器呈现页面时可能已经过时。如果您的用户需要获得最新的实时信息,他们可以不断地手动刷新页面,但这显然是不切实际的,也不是一个特别好的解决方案。

当前提供实时 web 应用的尝试主要围绕一种称为轮询 的技术,以模拟其他服务器端推送技术,其中最流行的是 Comet ,它基本上延迟了 HTTP 响应的完成,以将消息传递给客户端。

轮询是一种定时的同步调用,客户端向服务器发出请求,以查看是否有任何可用的信息。这些请求是定期提出的;无论是否有信息,客户端都会收到响应。具体来说,如果有可用的信息,服务器会发送它。如果没有可用信息,服务器将返回否定响应,客户端将关闭连接。

如果您知道消息传递的确切时间间隔,轮询是一个很好的解决方案,因为只有当您知道服务器上有可用的信息时,您才能同步客户端来发送请求。然而,实时数据通常是不可预测的,不必要的请求和多余的连接是不可避免的。因此,在低消息速率的情况下,您可能会不必要地打开和关闭许多连接。

长轮询 是另一种流行的通信方式,客户端向服务器请求信息,并在设定的时间段内打开连接。如果服务器没有任何信息,它会保持请求打开,直到它有客户端的信息,或者直到它到达指定的超时结束。此时,客户端向服务器重新请求信息。长轮询也被称为 Comet,我们前面提到过,或者反向 AJAX。Comet 延迟 HTTP 响应的完成,直到服务器有东西要发送给客户机,这种技术通常被称为挂起-获取或挂起-发送。重要的是要明白,当您的消息量很大时,长轮询并不能提供比传统轮询更好的性能,因为客户端必须不断地重新连接到服务器以获取新信息,导致网络行为等同于快速轮询。长轮询的另一个问题是缺乏标准实现。

使用 ,客户端发送一个请求,服务器发送并维护一个开放响应,该响应不断更新并保持开放(无限期地或在设定的时间段内)。每当消息准备好传递时,服务器都会更新响应。虽然流听起来像是适应不可预测的消息传递的一个很好的解决方案,但是服务器从不发出完成 HTTP 响应的信号,因此连接一直保持打开。在这种情况下,代理和防火墙可能会缓冲响应,从而增加消息传递的延迟。因此,在存在防火墙或代理的网络上,许多流式传输尝试都是脆弱的。

这些方法提供了几乎实时的通信,但是它们也涉及 HTTP 请求和响应头,其中包含大量额外的和不必要的头数据和延迟。此外,在每种情况下,客户端必须等待请求返回,然后才能启动后续请求,因此大大增加了延迟。

图 1-2 显示了这些连接在网络上的半双工性质,集成到一个架构中,在你的内部网中,你有通过 TCP 的全双工连接。

9781430247401_Fig01-02.jpg

图 1-2 。网络上的半双工;后端 TCP 上的全双工

WebSocket 简介

那么,这会给我们带来什么?为了消除这些问题,HTML5 规范的连接部分包含了 WebSocket。WebSocket 是一种自然的全双工、双向、单路连接。使用 WebSocket,您的 HTTP 请求变成了打开 WebSocket 连接的单个请求(WebSocket 或 TLS(传输层安全性,以前称为 SSL)上的 WebSocket),并重用从客户端到服务器以及从服务器到客户端的相同连接。

WebSocket 减少了延迟,因为一旦 WebSocket 连接建立,服务器就可以在消息可用时发送消息。例如,与轮询不同,WebSocket 只发出一个请求。服务器不需要等待来自客户端的请求。类似地,客户端可以随时向服务器发送消息。这个请求大大减少了轮询的延迟,轮询每隔一段时间发送一个请求,而不管消息是否可用。

图 1-3 比较了一个样本轮询场景和一个 WebSocket 场景。

9781430247401_Fig01-03.jpg

图 1-3 。轮询 vs WebSocket

本质上,WebSocket 符合语义和简化的 HTML5 范式。它不仅消除了对复杂工作区和延迟的需求,还简化了体系结构。让我们更深入地探究一下原因。

为什么需要 WebSocket?

现在我们已经探索了 WebSocket 的历史,让我们看看为什么你应该使用 WebSocket。

WebSocket 讲的是性能

WebSocket 使得实时通信更加高效。

您总是可以使用 HTTP 上的轮询(有时甚至是流)来接收 HTTP 上的通知。然而,WebSocket 节省了带宽、CPU 功率和延迟。

WebSocket 是性能上的创新。

WebSocket 讲的是简洁

WebSocket 使得客户机和服务器之间通过 Web 的通信更加简单。

那些已经经历过在 WebSocket 之前的体系结构中建立实时通信的痛苦的人知道,通过 HTTP 进行实时通知的技术过于复杂。跨无状态请求维护会话状态增加了复杂性。跨源 AJAX 很复杂,用 AJAX 处理有序请求需要特别考虑,用 AJAX 通信也很复杂。每次试图将 HTTP 扩展到非设计用例中都会增加软件的复杂性。

WebSocket 使您能够大大简化实时应用中面向连接的通信。

WebSocket 大约是标准

WebSocket 是一个底层网络协议,它使您能够在其上构建其他标准协议。

许多 web 应用本质上是单一的。大多数 AJAX 应用通常由紧密耦合的客户端和服务器组件组成。因为 WebSocket 自然支持高级应用协议的概念,所以您可以更加灵活地独立发展客户端和服务器。支持这些高级协议支持模块化,并鼓励可重用组件的开发。例如,您可以使用相同的 XMPP over WebSocket 客户端登录不同的聊天服务器,因为所有的 XMPP 服务器都理解相同的标准协议。

WebSocket 是互操作 web 应用的创新。

WebSocket 大约是html 5

WebSocket 是为 HTML5 应用提供高级功能的努力的一部分,以便与其他平台竞争。

每个操作系统都需要联网功能。应用打开套接字并与其他主机通信的能力是每个主要平台都提供的核心特性。从许多方面来说,HTML5 是使 web 浏览器成为类似于操作系统的全功能应用平台的趋势。像套接字这样的低级网络 API 无法与 Web 的原始安全模型或 API 设计风格相适应。WebSocket 为 HTML5 应用提供 TCP 风格的网络,而不会破坏浏览器的安全性它有一个现代的 API。

WebSocket 是 HTML5 平台的一个关键组件,对于开发者来说是一个非常强大的工具。

你需要 WebSocket!

简单来说,你需要 WebSocket 来构建世界级的 web 应用。WebSocket 解决了使 HTTP 不适合实时通信的主要缺陷。WebSocket 支持的异步双向通信模式是对 Internet 上传输层协议所提供的一般灵活性的回归。

想想使用 WebSocket 并在应用中构建真正的实时功能的所有好方法,如聊天、协作文档编辑、大型多人在线(MMO)游戏、股票交易应用等等。我们将在本书的后面看一下具体的应用。

WebSocket 和 RFC 6455

WebSocket 是一个协议,但也有一个 WebSocket API,它使您的应用能够控制 WebSocket 协议并响应服务器触发的事件。API 由 W3C(万维网联盟)开发,协议由 IETF(互联网工程任务组)开发。现代浏览器现在支持 WebSocket API,它包括使用全双工、双向 WebSocket 连接所需的方法和属性。API 使您能够执行必要的操作,如打开和关闭连接、发送和接收消息,以及侦听服务器触发的事件。第二章更详细地描述了 API,并举例说明了如何使用 API。

WebSocket 协议支持客户端和远程服务器之间通过 Web 进行全双工通信,并支持二进制数据和文本字符串的传输。该协议由一个开始握手和随后的基本消息组帧组成,位于 TCP 之上。第三章更详细地描述了该协议,并向您展示了如何创建自己的 WebSocket 服务器。

WebSocket 的世界

WebSocket API 和协议有一个蓬勃发展的社区,这反映在各种 WebSocket 服务器选项、开发人员社区和目前正在使用的无数现实生活中的 WebSocket 应用上。

WebSocket 选项

现在有各种各样的 WebSocket 服务器实现,比如 Apache mod_pywebsocket、Jetty、Socket。IO,以及 Kaazing 的 WebSocket 网关。

HTML5 WebSocket 的权威指南的想法源于分享我们多年来在 Kaazing 使用 WebSocket 和相关技术的知识、经验和观点的愿望。Kaazing 五年来一直在构建一个企业 WebSocket 网关服务器及其客户端库。

WebSocket 社区:它活了!

我们已经列出了一些使用 WebSocket 的理由,并将探索如何自己实现 WebSocket 的真实、适用的例子。除了各种可用的 WebSocket 服务器,WebSocket 社区也在蓬勃发展,特别是在 HTML5 游戏、企业消息和在线聊天方面。每天都有更多的会议和编码会议,不仅致力于 HTML5 的特定领域,还致力于实时通信方法,尤其是 WebSocket。即使是构建广泛使用的企业消息服务的公司也在将 WebSocket 集成到他们的系统中。因为 WebSocket 是基于标准的,所以很容易增强您现有的架构,标准化和扩展您的实现,以及构建以前不可能或难以构建的新服务。

围绕 WebSocket 的兴奋也反映在 GitHub 这样的在线社区中,在那里每天都有更多与 WebSocket 相关的服务器、应用和项目被创建。其他蓬勃发展的在线社区是http://www.websocket.org,它托管了一个 WebSocket 服务器,我们将在后续章节中以此为例,还有http://webplatform.orghttp://html5rocks.com,它们是开放的社区,鼓励共享所有与 HTML5 相关的信息,包括 WebSocket。

image 更多 WebSocket 服务器在附录 b 中列出

WebSocket 的应用

在写这本书的时候,WebSocket 正被广泛应用。以前的“实时”通信技术(如 AJAX)可以实现一些应用,但是它们已经显著提高了性能。外汇和股票报价应用也受益于 WebSocket 提供的减少的带宽和全双工连接。我们将在第三章中了解如何检查 WebSocket 流量。

随着浏览器应用部署的增加,HTML5 游戏开发也出现了热潮。WebSocket 是网络游戏的天然选择,因为游戏玩法和游戏交互对响应能力的依赖令人难以置信。使用 WebSocket 的 HTML5 游戏的一些示例是流行的在线赌博应用、通过 WebSocket 与 WebGL 集成的游戏控制器应用以及游戏中的在线聊天。还有一些非常令人兴奋的大型多人在线(MMO)游戏,广泛应用于各种移动和桌面设备的浏览器中。

相关技术

您可能会惊讶地发现,还有其他技术可以与 WebSocket 结合使用,或者作为 web socket 的替代技术。以下是一些其他新兴的网络通信技术。

服务器发送的事件

当您的架构需要双向、全双工通信时,WebSocket 是一个不错的选择。但是,如果您的服务主要是向其客户端广播或推送信息,并且不需要任何交互性(例如新闻提要、天气预报等),那么使用服务器发送事件(SSE)提供的 EventSource API 是一个不错的选择。SSE 是 HTML5 规范的一部分,它整合了一些 Comet 技术。可以将 SSE 用作 HTTP 轮询、长时间轮询和流式传输的通用互操作语法。使用 SSE,您可以获得自动重新连接、事件 id 等等。

image 注意虽然 WebSocket 和 SSE 连接都是以 HTTP 请求开始的,但是你看到的性能优势和它们的能力可能会有很大的不同。例如,SSE 不能将流数据从客户端向上游发送到服务器,并且只支持文本数据。

SPDY

SPDY(发音为“speedy”)是 Google 正在开发的一种网络协议,并且受到越来越多浏览器的支持,包括 Google Chrome、Opera 和 Mozilla Firefox。本质上,SPDY 通过压缩 HTTP 头和多路复用来增强 HTTP 以提高 HTTP 请求的性能。其主要目的是提高 web 页面的性能。虽然 WebSocket 专注于优化 web 应用前端和服务器之间的通信,但 SPDY 也优化了交付应用内容和静态页面。HTTP 和 WebSocket 的区别是架构上的,而不是增量的。SPDY 是 HTTP 的修订版,所以它共享相同的架构风格和语义。它修复了 HTTP 的许多非固有问题,增加了多路复用、工作流水线和其他有用的增强。WebSocket 消除了请求-响应风格的通信,支持实时交互和替代的架构模式。

WebSocket 和 SPDY 是互补的;您将能够将您的 SPDY 增强的 HTTP 连接升级到 WebSocket,从而在 SPDY 上使用 WebSocket,并从两个世界的优点中获益。

网络实时通讯

Web 实时通信 (WebRTC )是增强现代 web 浏览器通信能力的又一努力。WebRTC 是用于 Web 的对等技术。浏览器可以直接通信,不需要通过服务器传输所有数据。WebRTC 包括 API,让浏览器能够实时地相互通信。在写这本书的时候,WebRTC 仍然是万维网联盟(W3C)的草案格式,可以在http://www.w3.org/TR/webrtc/找到。

WebRTC 的第一个应用是实时语音和视频聊天。对于媒体应用来说,WebRTC 已经是一项引人注目的新技术,网上有许多可用的示例应用,使您能够通过 Web 上的视频和音频来测试这一点。

WebRTC 稍后将添加数据通道。为了一致性,这些数据通道计划使用与 WebSocket 类似的 API。此外,如果您的应用使用流媒体和其他数据,您可以同时使用 WebSocket 和 WebRTC。

摘要

在这一章中,我们向你介绍了 HTML5 和 WebSocket,并了解了一点 HTTP 的历史,它把我们带到了 WebSocket。我们希望到现在为止,您和我们一样兴奋地学习更多关于 WebSocket 的知识,进入代码,并梦想您能够用它做所有美好的事情。

在随后的章节中,我们将更深入地研究 WebSocket API 和协议,解释如何将 WebSocket 与标准的、更高级别的应用协议一起使用,讨论 WebSocket 的安全方面,并描述企业级的特性和部署。*

二、WebSocket API

本章向您介绍 WebSocket 应用编程接口 (API),您可以使用它来控制 WebSocket 协议和创建 WebSocket 应用。在本章中,我们将研究 WebSocket API 的构建块,包括它的事件、方法和属性。为了学习如何使用 API,我们编写了一个简单的客户端应用,连接到一个现有的、公开可用的服务器(http://websocket.org),它允许我们通过 WebSocket 发送和接收消息。通过使用现有的服务器,我们可以专注于学习使您能够创建 WebSocket 应用的易于使用的 API。我们还将逐步解释如何使用 WebSocket API 来支持使用二进制数据的 HTML5 媒体。最后,我们讨论浏览器支持和连接。

本章重点介绍 WebSocket 的客户端应用,它使您能够将 WebSocket 协议扩展到您的 web 应用。后续章节将描述 WebSocket 协议本身,以及在您的环境中使用 WebSocket。

WebSocket API 概述

正如我们在第一章中提到的,WebSocket 由网络协议和 API 组成,使您能够在客户端应用和服务器之间建立 WebSocket 连接。我们将在第三章中更详细地讨论这个协议,但是让我们先来看看 API。

WebSocket API 是一个使应用能够使用 WebSocket 协议的接口。通过在应用中使用 API,您可以控制一个全双工通信通道,应用可以通过该通道发送和接收消息。WebSocket 界面非常简单易用。要连接到远程主机,只需创建一个新的 WebSocket 对象实例,并为新对象提供一个 URL,该 URL 表示您希望连接的端点。

在客户端和服务器之间的初始握手期间,通过相同的底层 TCP 连接,通过从 HTTP 协议升级到 WebSocket 协议来建立 WebSocket 连接。一旦建立,WebSocket 消息可以在 WebSocket 接口定义的方法之间来回发送。在应用代码中,然后使用异步事件侦听器来处理连接生命周期的每个阶段。

WebSocket API 是纯粹的(也是真正的)事件驱动的。一旦建立了全双工连接,当服务器有数据要发送到客户端时,或者如果您关心的资源改变了它们的状态,它会自动发送数据或通知。有了事件驱动的 API,您不需要向服务器轮询目标资源的最新状态;相反,客户端只是监听想要的通知和更改。

我们将在后续章节谈到更高层协议时看到使用 WebSocket API 的不同例子,比如 STOMP和 XMPP 。但是现在,让我们仔细看看 API。

WebSocket API 入门

WebSocket API 使您能够通过 Web 在客户端应用和服务器端进程之间建立全双工双向通信。WebSocket 接口指定了客户端可用的方法以及客户端如何与网络交互。

首先,通过调用 WebSocket 构造函数,创建一个 WebSocket 连接。构造函数返回一个 WebSocket 对象实例。您可以监听该对象上的事件。这些事件告诉您连接何时打开、消息何时到达、连接何时关闭以及何时发生错误。您可以与 WebSocket 实例交互来发送消息或关闭连接。随后的章节将探讨 WebSocket API 的每一个方面。

WebSocket 构造函数

要建立到服务器的 WebSocket 连接,可以使用 WebSocket 接口通过指向表示要连接的端点的 URL 来实例化 WebSocket 对象。WebSocket 协议定义了两种 URI 方案,wswss,分别用于客户端和服务器之间的未加密和加密流量。ws (WebSocket) 方案类似于 HTTP URI 方案。wss (WebSocket Secure) URI 方案代表了一个基于传输层安全 (TLS,也称为 SSL)的 WebSocket 连接,并使用与 HTTPS 保护 HTTP 连接相同的安全机制。

image 我们将在第七章中深入讨论 WebSocket 安全性。

WebSocket 构造函数采用一个必需的参数URL(您要连接的 URL)和一个可选参数protocols(服务器必须在其响应中包含的单个协议名或协议名数组,以建立连接)。可以在protocols参数中使用的协议示例有 XMPP(可扩展消息和存在协议)、SOAP(简单对象访问协议)或自定义协议。

清单 2-1 展示了 WebSocket 构造函数中的一个必需参数,它必须是一个以ws://wss://方案开头的全限定 URL。在本例中,完全限定的 URL 是ws:// www.websocket.org。如果 URL 中有语法错误,构造函数将抛出异常。

清单 2-1 。 样本 WebSocket 构造函数

// Create new WebSocket connection

var ws = new WebSocket("[ws://www.websocket.org](http://www.websocket.org)");

当连接到 WebSocket 服务器时,可以选择使用第二个参数来列出应用支持的协议,即协议协商。

为了确保客户端和服务器发送和接收它们都理解的消息,它们必须使用相同的协议。WebSocket 构造函数使您能够定义客户端可以用来与服务器通信的一个或多个协议。服务器依次选择要使用的协议;在客户端和服务器之间只能使用一种协议。这些协议在 WebSocket 协议上使用。正如你将在第三章到第六章中了解到的,WebSocket 的一大好处是能够在 WebSocket 上对广泛使用的协议进行分层,这让你可以做一些伟大的事情,比如将传统的桌面应用带到网络上。

image 注意WebSocket 协议(RFC 6455)指的是可以作为“子协议”、与 web socket 一起使用的协议,即使它们是更高级的、完全形成的协议。在本书中,为了避免混淆,我们通常将可以与 WebSocket 一起使用的协议简称为“协议”。

在我们走得太远之前,让我们回到 API 中的 WebSocket 构造函数。在最初的 WebSocket 连接握手过程中,你会在第三章中了解到更多,客户端发送一个带有协议名的Sec-WebSocket-Protocol头。服务器选择零个或一个协议,并以与客户端请求的名称相同的Sec-WebSocket-Protocol报头进行响应;否则,它会关闭连接。

协议协商对于确定给定的 WebSocket 服务器支持哪个协议或协议版本非常有用。应用可能支持多种协议,并使用协议协商来选择特定服务器使用哪种协议。清单 2-2 显示了支持一个假想协议“myProtocol”的 WebSocket 构造函数:

清单 2-2 。 支持协议的示例 WebSocket 构造函数

// Connecting to the server with one protocol called myProtocol

var ws = new WebSocket("ws://echo.websocket.org", "myProtocol");

image 注意在清单 2-2 中,假设的协议“myProtocol”是一个定义明确的,甚至可能是注册的和标准化的,客户端应用和服务器都能理解的协议名称。

WebSocket 构造函数还可以包含一组客户机支持的协议名称,这让服务器决定使用哪一个。清单 2-3 显示了一个样本 WebSocket 构造函数,它有一个它支持的协议列表,用数组表示:

清单 2-3 。 支持协议的 WebSocket 构造器示例

// Connecting to the server with multiple protocol choices

var echoSocket = new WebSocket("ws://echo.websocket.org", [ "com.kaazing.echo", "example.imaginary.protocol"])

echoSocket.onopen = function(e) {
   // Check the protocol chosen by the server
   console.log( echoSocket.protocol);
}

在清单 2-3 中,因为 ws://echo.websocket.org 的 WebSocket 服务器只理解com.kaazing.echo协议而不理解example.imaginary.protocol,所以当 WebSocket open事件触发时,服务器选择 com.kaazing.echo 协议。使用数组可以让您的应用灵活地对不同的服务器使用不同的协议。

我们将在下一章深入讨论 WebSocket 协议,但本质上,有三种类型的协议可以用 protocols 参数来表示:

  • 已注册协议:已根据 RFC 6455(web socket 协议)正式注册的标准协议,并已向 IANA(Internet Assigned Numbers Authority,互联网号码分配机构)正式注册的协议。注册协议的一个例子是微软的 SOAP over WebSocket 协议。更多信息见http://www.iana.org/assignments/websocket/websocket.xml
  • 开放协议 : 广泛使用的标准化协议,如 XMPP 和 STOMP,这些协议尚未注册为官方标准协议。我们将在随后的章节中研究如何在 WebSocket 中使用这些类型的协议。
  • 自定义协议: 您已经编写并希望与 WebSocket 一起使用的协议。

在这一章中,我们将重点介绍如何使用 WebSocket API,就像您使用自己的自定义协议一样,并在后面的章节中研究如何使用开放协议。让我们分别看一下事件、对象和方法,并将它们放在一个工作示例中。

WebSocket 事件

WebSocket API 完全是事件驱动的。您的应用代码侦听 WebSocket 对象上的事件,以便处理传入的数据和连接状态的变化。WebSocket 协议也是事件驱动的。您的客户端应用不需要向服务器轮询更新的数据。服务器发送消息和事件时,它们将异步到达。

WebSocket 编程遵循异步编程模型,这意味着只要 WebSocket 连接是打开的,您的应用就只是侦听事件。您的客户端不需要主动轮询服务器来获取更多信息。要开始监听事件,只需向 WebSocket 对象添加回调函数。或者,可以使用addEventListener() DOM 方法将事件监听器添加到 WebSocket 对象中。

WebSocket 对象调度四个不同的事件:

  • 打开
  • 消息
  • 错误
  • 关闭

与所有 web APIs 一样,您可以使用on<eventname>处理程序属性以及addEventListener();方法来监听这些事件。

WebSocket 事件:打开

一旦服务器响应 WebSocket 连接请求,就会触发open 事件并建立连接。对open事件的相应回调称为onopen

清单 2-4 展示了当 WebSocket 连接建立后如何处理事件。

清单 2-4 。 示例打开事件处理程序

// Event handler for the WebSocket connection opening
ws.onopen = function(e) {
   console.log("Connection open...");
};

当 open 事件触发时,协议握手已经完成,WebSocket 准备好发送和接收数据。如果您的应用接收到一个 open 事件,您可以确定 WebSocket 服务器成功地处理了连接请求,并同意与您的应用进行通信。

WebSocket 事件:消息

WebSocket 消息包含来自服务器的数据。您可能也听说过 WebSocket 框架,它包含 WebSocket 消息。我们将在第三章中更深入地讨论消息和帧的概念。为了理解消息如何与 API 一起工作,WebSocket API 只公开完整的消息,而不公开 WebSocket 框架。收到消息时触发message事件。消息事件对应的回调称为onmessage

清单 2-5 显示了一个接收文本消息并显示消息内容的消息处理器。

清单 2-5 。 短信事件处理程序示例

// Event handler for receiving text messages
ws.onmessage = function(e) {
   if(typeof e.data === "string"){
      console.log("String message received", e, e.data);
   } else {
      console.log("Other message received", e, e.data);
   }
};

除了文本,WebSocket 消息还可以处理二进制数据,它们被处理为 Blob 消息,如清单 2-6 所示,或者被处理为 ArrayBuffer 消息,如清单 2-7 所示。因为 WebSocket 消息二进制数据类型的应用设置会影响传入的二进制消息,所以在读取数据之前,您必须决定要在客户端上为传入的二进制数据使用的类型。

清单 2-6 。Blob 消息的示例消息事件处理程序

// Set binaryType to blob (Blob is the default.)
ws.binaryType = "blob";

// Event handler for receiving Blob messages
ws.onmessage = function(e) {
   if(e.data instanceof Blob){
      console.log("Blob message received", e.data);
      var blob = new Blob(e.data);
   }
};

清单 2-7 显示了一个检查和处理 ArrayBuffer 消息的消息处理器。

清单 2-7 。array buffer 消息的示例消息事件处理程序

// Set binaryType to ArrayBuffer messages
ws.binaryType = "arraybuffer";

// Event handler for receiving ArrayBuffer messages
ws.onmessage = function(e) {
   if(e.data instanceof ArrayBuffer){
      console.log("ArrayBuffer Message Received", + e.data);
      // e.data is an ArrayBuffer. Create a byte view of that object.
      var a = new Uint8Array(e.data);
   }
};

WebSocket 事件:错误

为了响应意外失败,触发了error事件。对error事件的相应回调称为onerror 。错误还会导致 WebSocket 连接关闭。如果您收到一个错误事件,您可以期待一个关闭事件紧随其后。close 事件中的代码和原因有时可以告诉您是什么导致了错误。error event handler是调用服务器的重新连接逻辑和处理来自 WebSocket 对象的异常的好地方。清单 2-8 显示了一个如何监听error事件的例子。

清单 2-8 。 样本错误事件处理程序

// Event handler for errors in the WebSocket object
ws.onerror = function(e) {
   console.log("WebSocket Error: " , e);
   //Custom function for handling errors
   handleErrors(e);
};

WebSocket 事件:关闭

WebSocket 连接关闭时会触发close事件。对close事件的相应回调被称为onclose。一旦连接关闭,客户端和服务器就不能再接收或发送消息。

image 注意web socket 规范还定义了可用于保持活动、心跳、网络状态探测、延迟检测等的pingpong帧,但是 WebSocket API 目前并没有公开这些特性。尽管浏览器收到了一个ping帧,但它不会在相应的 WebSocket 上触发一个可见的ping事件。相反,浏览器会自动用一个pong框架来响应。然而,浏览器发起的 ping在一段时间后未被pong应答也可能触发连接close事件。第八章详细介绍了 WebSocket pings 和 pongs。

当您调用close()方法并终止与服务器的连接时,您也触发了onclose事件处理程序,如清单 2-9 所示。

清单 2-9 。 样本关闭事件处理程序

// Event handler for closed connections
ws.onclose = function(e) {
   console.log("Connection closed", e);
};

WebSocket close事件在连接关闭时被触发,这可能是由于多种原因,如连接失败或 WebSocket 关闭握手成功。WebSocket 对象属性readyState反映了连接的状态(2 表示关闭,3 表示关闭)。

close事件有三个有用的属性可以用于错误处理和恢复 : wasCleancodeerrorwasClean属性 是一个布尔值,指示连接是否被干净地关闭。如果 WebSocket 响应来自服务器的关闭帧而关闭,则属性为true。如果连接由于其他原因关闭(例如,因为底层 TCP 连接关闭),则wasClean属性为false。code 和 reason 属性指示从服务器传送的结束握手的状态。这些属性与WebSocket.close()方法中给出的代码和原因参数是对称的,我们将在本章后面详细描述。在第三章中,我们将在讨论 WebSocket 协议时讨论结束代码及其含义。

image 关于 WebSocket 事件的更多细节,参见http://www.w3.org/TR/websockets/的 WebSocket API 规范。

WebSocket 方法

WebSocket 对象有两种方法:send()close()

WebSocket 方法:send()

一旦使用 WebSocket 在客户机和服务器之间建立了全双工的双向连接,就可以在连接打开时调用send()方法(也就是说,在调用onopen监听器之后和调用onclose监听器之前)。您使用send()方法将消息从您的客户机发送到服务器。发送一条或多条消息后,您可以保持连接打开,或者调用close()方法来终止连接。

清单 2-10 是一个如何向服务器发送文本消息的例子。

清单 2-10 。 通过 WebSocket 发送短信

// Send a text message
ws.send("Hello WebSocket!");

send()方法在连接打开时传输数据。如果连接不可用或关闭,它将引发一个关于无效连接状态的异常。当开始使用 WebSocket API 时,人们犯的一个常见错误是试图在连接打开之前发送消息,如清单 2-11 所示。

清单 2-11 。 在打开连接之前尝试发送消息

// Open a connection and try to send a message. (This will not work!)
var ws = new WebSocket("ws://echo.websocket.org")
ws.send("Initial data");

清单 2-11 不会工作,因为连接还没有打开。相反,你应该在新构建的 WebSocket 上发送第一条消息之前等待open事件,如清单 2-12 所示。

清单 2-12 。?? 发送消息前等待打开事件

// Wait until the open event before calling send().
var ws = new WebSocket("ws://echo.websocket.org")
ws.onopen = function(e) {
   ws.send("Initial data");
}

如果您想发送消息来响应另一个事件,您可以检查 WebSocket readyState属性并选择仅在套接字打开时发送数据,如清单 2-13 所示。

清单 2-13 。 检查打开的 WebSocket 的 readyState 属性

// Handle outgoing data. Send on a WebSocket if that socket is open.
function myEventHandler(data) {
   if (ws.readyState === WebSocket.OPEN) {
      // The socket is open, so it is ok to send the data.
      ws.send(data);
   } else {
      // Do something else in this case.
      //Possibly ignore the data or enqueue it.
   }
}

除了文本(字符串)消息之外,WebSocket API 还允许您发送二进制数据,这对于实现二进制协议尤其有用。这种二进制协议可以是典型地位于 TCP 之上的标准互联网协议,其中有效载荷可以是 Blob 或 ArrayBuffer。清单 2-14 是一个如何通过 WebSocket 发送二进制消息的例子。

第六章展示了一个如何通过 WebSocket 发送二进制数据的例子。

清单 2-14 。 通过 WebSocket 发送二进制消息

// Send a Blob
var blob = new Blob("blob contents");
ws.send(blob);

// Send an ArrayBuffer
var a = new Uint8Array([8,6,7,5,3,0,9]);
ws.send(a.buffer);

Blob 对象在与 JavaScript 文件 API 结合用于发送和接收文件时特别有用,这些文件主要是多媒体文件、图像、视频和音频。本章末尾的示例代码将 WebSocket API 与 File API 结合使用,读取文件的内容,并将其作为 WebSocket 消息发送。

WebSocket 方法: close()

要关闭 WebSocket 连接或终止连接尝试,请使用close()方法。如果连接已经关闭,则该方法不执行任何操作。调用close()后,就不能在关闭的 WebSocket 上再发送任何数据了。清单 2-15 显示了一个close()方法的例子:

清单 2-15 。 调用close()方法

// Close the WebSocket connection
ws.close();

您可以选择向close()方法传递两个参数:code(一个数字状态代码)和reason(一个文本字符串)。传递这些参数会将有关客户端关闭连接的原因的信息传递给服务器。我们将在第三章中更详细地讨论状态代码和原因,当我们讨论 WebSocket 关闭握手时。清单 2-16 显示了一个用参数调用close()方法的例子。

清单 2-16 。 调用close()方法有原因

// Close the WebSocket connection because the session has ended successfully
ws.close(1000, "Closing normally");

清单 2-16 使用代码 1000,正如代码中所述,这意味着连接正常关闭。

WebSocket 对象属性

有几个 WebSocket 对象属性可以用来提供关于 WebSocket 对象的更多信息:readyStatebufferedAmount协议

WebSocket 对象属性:readyState

WebSocket 对象通过只读属性readyState报告连接的状态,您在前面的章节中已经了解了一些。该属性根据连接状态自动更改,并提供有关 WebSocket 连接的有用信息。

表 2-1 描述了四个不同的值,可以将readyState属性设置为这些值来描述连接状态。

表 2-1 。就绪状态属性,值和状态描述

属性常数 价值 状态
WebSocket。连接 Zero 连接正在进行中,但尚未建立。
websxmlsocket . open one 连接已经建立。消息可以在客户端和服务器之间流动。
WebSocket。结束的 Two 连接正在通过结束握手。
WebSocket。关闭的 three 连接已关闭或无法打开。

(万维网联盟,2012 年)

正如 WebSocket API 所描述的,当 WebSocket 对象第一次被创建时,它的readyState0,表示套接字正在连接。了解 WebSocket 连接的当前状态可以帮助您调试应用,例如,确保在尝试开始向服务器发送请求之前已经打开了 WebSocket 连接。这些信息也有助于理解您的连接的生命周期。

WebSocket 对象属性:bufferedAmount

设计应用时,您可能希望检查为传输到服务器而缓冲的数据量,特别是当客户端应用向服务器传输大量数据时。尽管调用send()是即时的,但通过互联网传输数据却不是。浏览器将代表您的客户端应用缓冲传出的数据,因此您可以随时调用send(),使用您喜欢的数据。但是,如果您想知道数据流出网络的速度有多快,WebSocket 对象可以告诉您缓冲区的大小。您可以使用bufferedAmount属性来检查已经排队但尚未传输到服务器的字节数。此属性中报告的值不包括协议引起的帧开销或操作系统或网络硬件完成的缓冲。

清单 2-17 展示了一个如何使用bufferedAmount属性每秒发送更新的例子;如果网络无法处理该速率,它会相应地进行调整。

清单 2-17 。 bufferedAmount 示例

// 10k max buffer size.
var THRESHOLD = 10240;

// Create a New WebSocket connection
var ws = new WebSocket("ws://echo.websocket.org/updates");

// Listen for the opening event
ws.onopen = function () {

   // Attempt to send update every second.
   setInterval( function() {
      // Send only if the buffer is not full
      if (ws.bufferedAmount < THRESHOLD) {
         ws.send(getApplicationState());
      }
   }, 1000);
};

使用bufferedAmount属性有助于限制应用向服务器发送数据的速率,从而避免网络饱和。

image 专业提示在尝试关闭连接之前,您可能希望检查 WebSocket 对象的bufferedAmount属性,以确定是否有任何数据尚未从应用传输。

WebSocket 对象属性:协议

在我们之前关于 WebSocket 构造函数的讨论中,我们提到了protocol参数,它让服务器知道客户端理解并可以通过 WebSocket 使用哪个协议。WebSocket 对象protocol属性提供了另一条关于 WebSocket 实例的有用信息。客户端和服务器之间的协议协商结果在 WebSocket 对象上是可见的。protocol属性包含 WebSocket 服务器在开始握手时选择的协议名称。换句话说,protocol属性告诉您特定的 WebSocket 使用哪种协议。在开始握手完成之前,protocol属性是一个空字符串,如果服务器没有选择客户端提供的协议之一,它就仍然是一个空字符串。

将所有这些放在一起

既然我们已经走过了 WebSocket 构造函数、事件、属性和方法,那么让我们把我们所学到的关于 WebSocket API 的东西放在一起。这里,我们创建一个客户机应用,通过 Web 与远程服务器通信,并使用 WebSocket 交换数据。我们的示例 JavaScript 客户机使用托管在ws://echo.websocket.org的“Echo”服务器,它接收并返回您发送给服务器的任何消息。使用 Echo 服务器对于纯客户端测试非常有用,特别是对于理解 WebSocket API 如何与服务器交互。

首先,我们创建连接,然后在网页上显示由我们的代码触发的事件,这些事件来自服务器。该页面将显示有关客户端连接到服务器、向服务器发送消息和从服务器接收消息,然后从服务器断开连接的信息。

清单 2-18 显示了一个与服务器通信和消息传递的完整例子。

清单 2-18 。 使用 WebSocket API 完成客户端应用

<!DOCTYPE html>
<title>WebSocket Echo Client</title>
<h2>Websocket Echo Client</h2>

<div id="output"></div>
<script>

// Initialize WebSocket connection and event handlers

function setup() {
   output = document.getElementById("output");
   ws = new WebSocket("ws://echo.websocket.org/echo");

// Listen for the connection open event then call the sendMessage function
   ws.onopen = function(e) {
      log("Connected");
      sendMessage("Hello WebSocket!")
   }

// Listen for the close connection event
   ws.onclose = function(e) {
      log("Disconnected: " + e.reason);
   }

// Listen for connection errors
   ws.onerror = function(e) {
      log("Error ");
   }

// Listen for new messages arriving at the client
   ws.onmessage = function(e) {
      log("Message received: " + e.data);
      // Close the socket once one message has arrived.
      ws.close();
   }
}

// Send a message on the WebSocket.
function sendMessage(msg){
   ws.send(msg);
      log("Message sent");
   }

// Display logging information in the document.
function log(s) {
   var p = document.createElement("p");
   p.style.wordWrap = "break-word";
   p.textContent = s;
   output.appendChild(p);

   // Also log information on the javascript console
   console.log(s);
}

// Start running the example.
setup();
</script>

运行网页后,输出应类似于以下内容:

WebSocket Sample Client

Connected

Message sent

Message received: Hello WebSocket!

Disconnected

如果你看到这个输出,恭喜你!您已经成功地创建并执行了第一个示例 WebSocket 客户端应用。如果这个例子不起作用,你需要调查它失败的原因。您可以在浏览器的 JavaScript 控制台中找到有用的信息。虽然可能性越来越小,但您的浏览器可能不支持 WebSocket。虽然每个主流浏览器的最新版本都包含对 WebSocket API 和协议的支持,但仍有一些旧浏览器不支持这种支持。下一节将向您展示如何确保您的浏览器支持 WebSocket。

检查 WebSocket 支持

因为(令人惊讶的)并非所有的 web 浏览器都支持 WebSocket,所以在代码中包含一种确定浏览器支持的方法是一个好的实践,如果可能的话,提供一个后备。大多数现代浏览器都支持 WebSocket,但是根据用户的不同,您可能希望使用这些技术中的一种来覆盖您的基础。

image 第八章讨论了各种 WebSocket 回退和仿真选项。

有几种方法可以确定自己的浏览器是否支持 WebSocket。用来研究代码的一个便利工具是 web 浏览器的 JavaScript 控制台。每个浏览器都有不同的方式来启动 JavaScript 控制台。例如,在谷歌 Chrome 中,你可以通过选择视图image开发者image开发者工具,然后点击控制台来打开控制台。有关 Chrome 开发者工具的更多信息,请参见https://developers.google.com/chrome-developer-tools/docs/overview

image 专业提示谷歌的 Chrome 开发者工具也能让你检查 WebSocket 流量。为此,在“开发工具”面板中,单击“网络”,然后在面板底部,单击“WebSockets”。附录 A 详细介绍了有用的 WebSocket 调试工具。

打开浏览器的交互式 JavaScript 控制台,评估表达式window.WebSocket。如果您看到 WebSocket 构造函数对象,这意味着您的 web 浏览器本机支持 WebSocket。如果您的浏览器支持 WebSocket,但是您的示例代码不工作,您将需要进一步调试您的代码。如果对同一个表达式求值,得到的结果是空的或未定义的,那么您的浏览器本身就不支持 WebSocket。

为了确保您的 WebSocket 应用在不支持 WebSocket 的浏览器中工作,您需要考虑回退或仿真策略。您可以自己编写(这非常复杂),使用 polyfill(一个为旧浏览器复制标准 API 的 JavaScript 库),或者使用 Kaazing 这样的 WebSocket 供应商,它支持 WebSocket 仿真,使任何浏览器(回到 Microsoft Internet Explorer 6)都支持 HTML5 WebSocket 标准 API。作为将 WebSocket 应用部署到企业的一部分,我们将在第八章中进一步讨论这些选项。

作为应用的一部分,您可以添加一个对 WebSocket 支持的条件检查,如清单 2-19 所示。

清单 2-19 。中的客户端代码确定浏览器中的 WebSocket 支持

if (window.WebSocket){
    console.log("This browser supports WebSocket!");
} else {
    console.log("This browser does not support WebSocket.");
}

image 注意网上有很多描述 HTML5 和 WebSocket 与浏览器兼容的资源,包括手机浏览器。两个这样的资源是http://caniuse.com/http://html5please.com/??。

通过 WebSocket 使用 HTML5 媒体

作为 HTML5 和 Web 平台的一部分,WebSocket API 被设计为能够很好地与 HTML5 的所有特性一起工作。可以用 API 发送和接收的数据类型对于传输应用数据和媒体非常有用。当然,字符串允许您表示像 XML 和 JSON 这样的 web 数据格式。二进制类型集成了 API,如拖放、FileReader、WebGL 和 Web Audio API。

让我们来看看如何通过 WebSocket 使用 HTML5 媒体。清单 2-20 显示了一个完整的客户端应用使用 HTML5 媒体和 WebSocket。您可以基于这段代码创建自己的 HTML 文件。

image 注意要构建(或简单地遵循)本书中的示例,您可以选择使用我们创建的虚拟机(VM ),它包含了我们在示例中使用的所有代码、库和服务器。有关如何下载、安装和启动虚拟机的说明,请参考附录 B。

清单 2-20 。 通过 WebSocket 使用 HTML5 媒体完成客户端应用

<!DOCTYPE html>
<title>WebSocket Image Drop</title>
<h1>Drop Image Here</h1>
<script>

// Initialize WebSocket connection
var wsUrl = "ws://echo.websocket.org/echo";
var ws = new WebSocket(wsUrl);
ws.onopen = function() {
 console.log("open");
}

// Handle binary image data received on the WebSocket
ws.onmessage = function(e) {
   var blob = e.data;
   console.log("message: " + blob.size + " bytes");
   // Work with prefixed URL API
  if (window.webkitURL) {
      URL = webkitURL;
   }

   var uri = URL.createObjectURL(blob);
   var img = document.createElement("img");
   img.src = uri;
   document.body.appendChild(img);
}

// Handle drop event
document.ondrop = function(e) {
   document.body.style.backgroundColor = "#fff";
   try {
      e.preventDefault();
     handleFileDrop(e.dataTransfer.files[0]);
     return false;
   } catch(err) {
     console.log(err);
   }
}

// Provide visual feedback for the drop area
document.ondragover = function(e) {
   e.preventDefault();
   document.body.style.backgroundColor = "#6fff41";
}
document.ondragleave = function() {
  document.body.style.backgroundColor = "#fff";
}

// Read binary file contents and send them over WebSocket
function handleFileDrop(file) {
   var reader = new FileReader();
   reader.readAsArrayBuffer(file);
   reader.onload = function() {
     console.log("sending: " + file.name);
     ws.send(reader.result);
   }
}
</script>

在您最喜欢的现代浏览器中打开此文件。当 WebSocket 连接打开时,查看一下浏览器的 JavaScript 控制台。图 2-1 显示了运行在 Mozilla Firefox 中的客户端应用。注意,在这个图的底部,我们显示了 Firebug(一个强大的 web 开发和调试工具,可在http://getfirebug.com获得)中提供的 JavaScript 控制台。

9781430247401_Fig02-01.jpg

图 2-1 。使用 HTML5 媒体的客户端应用,带有在 Mozilla Firefox 中显示的 WebSocket】

现在,试着将一个图像文件拖放到这个页面上。将图像文件拖放到页面上之后,您应该会看到呈现在网页上的图像,如图 2-2 所示。请注意 Firebug 是如何显示添加到页面中的图像文件的信息的。

9781430247401_Fig02-02.jpg

图 2-2 。使用 HTML5 媒体和 Mozilla Firefox 中的 WebSocket 在客户端应用中显示的图像(PNG)

image 注意服务器websocket.org目前只接受小消息,所以这个例子只适用于小于 65kb 的图像文件,尽管这个限制可能会改变。您可以在自己的服务器上试验更大的媒体。

这个演示的“惊喜”因素可能会因为媒体来自最终显示它的同一个浏览器而减少。你可以用 AJAX 甚至不用网络来实现同样的视觉效果。当客户端或服务器发送一些媒体数据,并由不同的浏览器显示时,事情变得非常有趣——甚至是成千上万的其他浏览器!在广播场景中,读取和显示二进制图像数据的机制与这个简化的 echo 演示相同。

摘要

在本章中,您学习了 WebSocket API 的各个方面,它使您能够从浏览器中运行的客户端应用启动 WebSocket 连接,并通过 WebSocket 连接从服务器向客户端发送消息。您了解了 WebSocket API 背后的基本概念,包括事件、消息和属性,还了解了一些 API 的实际例子。您还了解了如何使用公开可用的 WebSocket Echo 服务器创建自己的 WebSocket 应用,您可以使用它来进一步测试自己的应用。有关该接口的权威定义,请参见位于http://www.w3.org/TR/websockets/的完整 WebSocket API 规范。

在第三章中,您将学习 WebSocket 协议,并逐步构建您自己的基本 WebSocket 服务器。

三、WebSocket 协议

WebSocket 是一种网络协议,它定义了服务器和客户端如何通过 Web 进行通信。协议是商定的通信规则。组成互联网的一套协议由互联网工程任务组 IETF 发布。IETF 发布了称为 RFC 的征求意见稿,RFC 精确地指定了协议,包括 RFC 6455:web socket 协议。RFC 6455 发布于 2011 年 12 月,包含了实现 WebSocket 客户端或服务器时必须遵循的确切规则。

在前一章中,我们探讨了 WebSocket API,它允许应用与 WebSocket 协议进行交互。在这一章中,我们将带您了解互联网和协议的简史,为什么创建 WebSocket 协议,以及它是如何工作的。我们使用网络工具来观察和了解 WebSocket 网络流量。使用一个用 JavaScript 和 Node.js 编写的示例 WebSocket 服务器,我们研究 WebSocket 握手如何建立 WebSocket 连接,消息如何编码和解码,以及连接如何保持活动和关闭。最后,我们使用这个示例 WebSocket 服务器同时远程控制几个浏览器。

在 WebSocket 协议之前

为了更好地理解 WebSocket 协议,让我们通过快速浏览来看看 WebSocket 如何适应一个重要的协议家族,从而了解一些历史背景。

协议!

协议是计算最重要的部分之一。它们跨越编程语言、操作系统和硬件架构。它们允许由不同的人编写并由不同的代理操作的组件在房间内或世界范围内相互通信。在开放的、可互操作的系统中,许多成功的故事都归功于设计良好的协议。

在万维网及其组成技术(如 HTML 和 HTTP)出现之前,互联网是一个非常不同的网络。一方面,它要小得多,另一方面,它本质上是一个同辈人的网络。当在互联网主机之间进行通信时,两种协议过去是并且现在仍然是流行的:互联网协议(IP,其负责在互联网上的两个主机之间简单地传输分组)和传输控制协议(TCP,其可以被视为在互联网上延伸的管道,并且在两个端点之间的每个方向上承载可靠的字节流)。总之,TCP over IP (TCP/IP)一直是并将继续是无数网络应用使用的核心传输层协议。

互联网简史

一开始,互联网主机之间有 TCP/IP 通信。在这种情况下,任何一台主机都可以建立新的连接。TCP 连接一旦建立,任何一台主机都可以随时发送数据,如图图 3-1 所示。

9781430247401_Fig03-01.jpg

图 3-1 。互联网主机之间的 TCP/IP 通信

网络协议中可能需要的任何其他功能都必须建立在传输协议之上。这些更高层被称为应用协议 。例如,在 Web 出现之前的两个重要的应用层协议是用于聊天的 IRC 和用于远程终端访问的 Telnet 。IRC 和 Telnet 显然需要异步双向通信。当另一个用户发送聊天消息或远程应用打印一行输出时,客户端必须收到提示通知。由于这些协议通常在 TCP 上运行,异步双向通信总是可用的。IRC 和 Telnet 会话保持持久的连接,客户机和服务器可以在任何时候自由地互相发送消息。TCP/IP 也是另外两个重要协议的基础:HTTP 和 WebSocket。不过,在我们开始之前,让我们先简要地看一下 HTTP。

Web 和 HTTP

1991 年,万维网项目以其最早的公开形式被宣布。网络是一个使用统一资源定位符(URL)链接超文本文档的系统。当时,URL 是一项重大创新。URL 中的 U 代表 universal,表明了当时革命性的想法,即所有超文本文档都可以相互连接。Web 上的 HTML 文档通过 URL 链接到其他文档。因此,网络协议是为获取资源而定制的是有道理的。HTTP 是一种简单的同步请求-响应式文档传输协议。

最早的 web 应用使用表单和整页重载。每次用户提交信息时,浏览器都会提交一个表单并获取一个新页面。每次有更新的信息要显示时,用户或浏览器都必须刷新整个页面,才能使用 HTTP 获取完整的资源。

有了 JavaScript 和 XMLHttpRequest API,一套被称为 AJAX 的技术被开发出来,以允许更无缝的应用,在每次交互过程中不会有突然的转变。AJAX 让应用只获取感兴趣的资源数据,并在没有导航的情况下更新页面。用 AJAX,网络协议还是 HTTP 尽管有 XMLHttpRequest 名称,但数据有时(但不总是)是 XML。

网络已经变得相当流行。如此受欢迎,事实上,许多人混淆了网络和互联网,因为网络通常是他们使用的唯一重要的互联网应用。 NAT(网络地址转换)、HTTP 代理和防火墙也变得越来越普遍。今天,许多互联网用户没有公开可见的 IP 地址。用户没有唯一的 IP 地址的原因有很多,包括安全措施、过度拥挤和缺乏必要。地址的缺乏妨碍了可寻址性;例如,需要公共地址的蠕虫无法访问未编址的用户。此外,没有足够的 IPv4 地址供所有 Web 用户使用。NAT 允许用户共享公共 IP 地址,同时仍能在网上冲浪。最后,占主导地位的协议 HTTP 不需要可寻址的客户端。HTTP 对于由客户端应用驱动的交互工作得相当好,因为客户端发起每个 HTTP 请求,如图图 3-2 所示:

9781430247401_Fig03-02.jpg

图 3-2 。HTTP 客户端将连接到一个网络服务器

本质上,HTTP 通过其对文本 (从而支持我们互连的 HTML 页面)、URL 和 HTTPS(安全 HTTP over 传输层安全性(TLS) )的内置支持使 Web 成为可能。然而,在某些方面,HTTP 也因其流行而导致互联网倒退。因为 HTTP 不需要可寻址的客户端,所以网络世界中的寻址是不对称的。浏览器可以通过 URL 访问服务器上的资源,但是服务器端应用无法主动将资源发送给客户端。客户端只能发出请求,服务器只能响应未完成的请求。在这个不对称的世界里,要求全双工通信的协议并不奏效。

解决这一限制的一种方法是让客户机打开 HTTP 请求,以防服务器有更新要共享。使用 HTTP 请求来逆转通知流的总称称为“Comet”正如我们在前面章节中所讨论的,Comet 基本上是一组技术,通过轮询、长时间轮询和流式传输将 HTTP 扩展到极限。这些技术本质上模拟了 TCP 的一些功能,以便处理相同的服务器到客户端用例。由于同步 HTTP 和这些异步应用之间的不匹配,Comet 往往是复杂的、非标准的和低效的。

image 在服务器对服务器的通信中,每台主机都可以寻址对方。当有新数据可用时,一个服务器可以简单地向另一个服务器发出 HTTP 请求,这就是用于服务器到服务器提要更新通知的 PubSubHubbub 协议的情况。PubSubHubbub 是一个开放协议,扩展了 RSS 和 Atom ,支持 HTTP 服务器之间的发布/订阅通信。虽然使用 WebSocket 可以实现服务器到服务器的通信,但本书主要关注实时 web 应用中的客户机-服务器通信。

介绍 WebSocket 协议

这一简短的互联网历史课程将我们带到了今天。现在,web 应用非常强大,具有重要的客户端状态和逻辑。通常,现代 web 应用需要双向通信。更新的即时通知更像是常规而非例外,用户越来越期望响应性的实时交互。让我们来看看 WebSocket 给了我们什么。

WebSocket:网络应用的互联网功能

WebSocket 保留了我们喜欢的 HTTP for web 应用的许多特性(URL、HTTP 安全性、更简单的基于消息的数据模型和对文本的内置支持),同时支持其他网络架构和通信模式。和 TCP 一样,WebSocket 是异步的,可以作为更高层协议的传输层。WebSocket 是消息协议、聊天、服务器通知、流水线和多路复用协议、自定义协议、压缩二进制协议和其他用于与 Internet 服务器互操作的标准协议的良好基础。

WebSocket 为 web 应用提供了 TCP 风格的网络功能。寻址仍然是单向的。服务器可以异步地向客户端发送数据,但是只有在有一个打开的 WebSocket 连接时。WebSocket 连接总是从客户端建立到服务器。WebSocket 服务器也可以充当 WebSocket 客户端。然而,使用 WebSocket,像浏览器这样的 web 客户端不能接受不是由它们发起的连接。图 3-3 显示了连接到服务器的 WebSocket 客户端,客户端或者服务器可以随时发送数据。

9781430247401_Fig03-03.jpg

图 3-3 。连接到服务器的 WebSocket 客户端

WebSocket 桥接了网络世界和互联网世界(或者更具体地说,TCP/IP)。以前不容易与 web 应用一起使用的异步协议现在可以使用 WebSocket 轻松地进行通信。表 3-1 比较了 TCP、HTTP 和 WebSocket 的主要领域。

表 3-1 。TCP、HTTP 和 WebSocket 的比较

image

TCP 只传递字节流 ,所以消息边界必须用更高层的协议来表示。初学使用 TCP 的 socket 程序员常犯的一个错误是,假设每个对send()的调用都会导致一个成功的receive.,虽然对于简单的测试来说这可能是真的,但是当负载和延迟变化时,在 TCP socket 上发送的字节会不可预测地被分段。根据操作系统的判断,TCP 数据可以分布在多个 IP 数据包上,也可以组合成较少的数据包。TCP 中唯一的保证是到达接收端的单个字节将按顺序到达。与 TCP 不同,WebSocket 传输一系列离散的消息 。有了 WebSocket,多字节的消息会完整有序的到达,就像 HTTP 一样。因为消息边界内置于 WebSocket 协议中,所以可以发送和接收单独的消息,并避免常见的分段错误。

值得一提的是,在互联网出现之前,另一种网络模型正在被采用:开放系统互连(OSI) ,它包括七层:物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。然而,尽管术语可能相似,OSI 在设计时并没有考虑到互联网。TCP/IP 模型是为互联网设计的,它只包括四层:链路层、Internet 层、传输层和应用层,并且是今天驱动互联网的模型。

IP 位于互联网层,TCP 位于 IP 之上的传输层。WebSocket 位于 TCP(因此也是 IP)之上,它也被认为是传输层,因为您可以在 WebSocket 之上放置应用级协议。

检测 WebSocket 流量

在第二章中,我们使用了 WebSocket API,却没有真正看到网络层发生了什么。如果您想要查看网络上的 WebSocket 流量,您可以使用 Wireshark ( http://www.wireshark.org/)或 tcpdump ( http://www.tcpdump.org/)等工具,并检查通信堆栈内部的内容。Wireshark 使您能够“剖析”WebSocket 协议,这使您可以在一个方便的 UI 中查看我们将在本章稍后讨论的 WebSocket 协议的各个部分(例如,操作码、标志和有效载荷),如图图 3-4 所示。它甚至会显示从 WebSocket 客户端发送的消息的非屏蔽版本。我们将在本章后面讨论屏蔽。

9781430247401_Fig03-04.jpg

图 3-4 。在 Wireshark 中查看 WebSocket 会话

image 附录 A 详细介绍了 WebSocket 流量调试工具。

WebKit (驱动谷歌 Chrome 和苹果 Safari 的浏览器引擎)最近也增加了对检查 WebSocket 流量的支持。在最新版本的 Chrome 浏览器中,你可以在开发者工具的网络标签中看到 WebSocket 消息。图 3-5 展示了这个用 WebSocket 开发的必备工具。

9781430247401_Fig03-05.jpg

图 3-5 。使用谷歌浏览器开发工具查看网络套接字会话

我们强烈建议使用这些工具来观察 WebSockets 的运行,不仅是为了了解协议,也是为了更好地理解 WebSocket 会话期间发生的事情。

WebSocket 协议

让我们仔细看看 WebSocket 协议。对于协议的每个部分,我们将查看 JavaScript 代码来处理特定的语法。之后,我们将把这些片段组合成一个示例服务器库和两个简单的应用。

WebSocket 开场握手

每个 WebSocket 连接都以一个 HTTP 请求开始。这个请求与其他请求很相似,除了它包含一个特殊的头:UpgradeUpgrade报头表示客户端想要将连接升级到不同的协议。在本例中,不同的协议是 WebSocket。

让我们看一个在连接到ws://echo.websocket.org/echo时记录的握手示例。在握手完成之前,WebSocket 会话符合 HTTP/1.1 协议。客户端发送如清单 3-1 所示的 HTTP 请求。

清单 3-1。来自客户端的 HTTP 请求

GET /echo HTTP/1.1
Host: echo.websocket.org
Origin:http://www.websocket.org
Sec-WebSocket-Key: 7+C600xYybOv2zmJ69RQsw==
Sec-WebSocket-Version: 13
Upgrade: websocket

清单 3-2 显示了服务器发回响应。

清单 3-2。 来自服务器的 HTTP 响应

101 Switching Protocols
Connection: Upgrade
Date: Wed, 20 Jun 2012 03:39:49 GMT
Sec-WebSocket-Accept: fYoqiH14DgI+5ylEMwM2sOLzOi0=
Server: Kaazing Gateway
Upgrade: WebSocket

图 3-6 说明了从客户端到服务器的对 WebSocket 的 HTTP 请求升级,也称为 WebSocket 开放握手。

9781430247401_Fig03-06.jpg

图 3-6 。WebSocket 开放式握手示例

图 3-6 显示了必需和可选的接头。一些头是严格必需的,必须存在并且精确,WebSocket 连接才能成功。这个握手中的其他头是可选的,但是是允许的,因为握手是一个 HTTP 请求和响应。成功升级后,连接的语法切换到用于表示 WebSocket 消息的数据帧格式。除非服务器用101响应码、Upgrade报头和Sec-WebSocket-Accept报头响应,否则连接不会成功。Sec-WebSocket-Accept响应头的值来源于Sec-WebSocket-Key请求头,包含一个特殊的键响应,它必须与客户端期望的完全匹配。

计算按键响应

为了成功完成握手,WebSocket 服务器必须用一个计算出的密钥进行响应。这个响应表明服务器特别理解 WebSocket 协议。如果没有确切的响应,就有可能欺骗一些毫无戒心的 HTTP 服务器,使其意外地升级连接!

这个响应函数从客户端发送的Sec-WebSocket-Key头中获取键值,并在返回的Sec-WebSocket-Accept头中返回客户端期望的计算值。清单 3-3 使用 Node.js 加密 API 来计算组合密钥和后缀的 SHA1 散列:

清单 3-3。 使用 Node.jspto API Crypto API 计算密钥响应

var KEY_SUFFIX = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
var hashWebSocketKey = function(key) {
   var sha1 = crypto.createHash("sha1");
   sha1.update(key + KEY_SUFFIX, "ascii");
   return sha1.digest("base64");
   }

image 清单 3-3 中的 KEY_SUFFIX 是协议规范中包含的一个常量 KEY 后缀,每个 WebSocket 服务器都必须知道。

在 WebSocket 开始握手和计算密钥响应时,WebSocket 协议依赖于 RFC 6455 中定义的 Sec-header。表 3-2 描述了这些 web socket Sec-header。

表 3-2 。WebSocket Sec- Headers 及其描述(RFC 6455

页眉 描述
sec-web 套接字密钥 在 HTTP 请求中只能出现一次。用于从客户端到服务器的开放式 WebSocket 握手,以防止跨协议攻击。参见 Sec-WebSocket-Accept。
sec-web socket-接受 在 HTTP 响应中只能出现一次。用于从服务器到客户端的 WebSocket 握手,以确认服务器理解 WebSocket 协议。
sec-web socket-扩展 可以在 HTTP 请求中出现多次(逻辑上与包含所有值的单个 Sec-WebSocket-Extensions 头字段相同),但在 HTTP 响应中只能出现一次。用于 WebSocket 从客户端到服务器,然后从服务器到客户端的开放握手。这个头帮助客户机和服务器就一组协议级扩展达成一致,以便在连接期间使用。
sec-web 套接字协议 用于从客户端到服务器的 WebSocket 握手,然后从服务器协商一个子协议。这个头通告了客户端应用可以使用的协议。服务器使用相同的报头来选择这些协议中的最多一个。
sec-web socket-版本 在从客户端到服务器的开始 WebSocket 握手中使用,以指示版本兼容性。RFC 6455 的版本始终是 13。如果服务器不支持客户端请求的协议版本,它会用此报头进行响应。在这种情况下,服务器发送的标题列出了它支持的版本。这只有在客户端早于 RFC 6455 时才会发生。

消息格式

当 WebSocket 连接打开时,客户端和服务器可以随时相互发送消息。这些消息在网络上用二进制语法表示,该语法标记消息之间的边界,并包括简明的类型信息。更准确地说,这些二进制头标记了其他东西之间的边界,称为。帧是可以组合形成消息的部分数据。在关于 WebSocket 的讨论中,您可能会看到“帧”和“消息”交替使用。使用这两个术语是因为(至少目前)很少在每条消息中使用一个以上的帧。此外,在早期的协议草案中,帧消息,网络上的消息表示被称为成帧。

你会回忆起第二章中的内容,WebSocket API 没有向应用公开框架级的信息。即使 API 是根据消息工作的,也可以在协议层处理子消息数据单元。一条消息中通常只有一个帧,但是一条消息可以由任意数量的帧组成。在全部数据可用之前,服务器可以使用不同数量的帧来开始传送数据。

让我们仔细看看 WebSocket 框架的各个方面。图 3-7 说明了 WebSocket 帧头。

9781430247401_Fig03-07.jpg

图 3-7 。WebSocket 帧头

WebSocket 成帧代码负责:

  • 操作码
  • 长度
  • 解码文本
  • 掩饰
  • 多帧消息

操作码

每个 WebSocket 消息都有一个指定消息有效负载类型的操作码。操作码由帧头第一个字节的后四位组成。操作码有一个数值,如表 3-3 所述。

表 3-3 。定义的操作码

操作码 消息负载的类型 描述
one 文本 消息的数据类型是文本。
Two 二进制的 消息的数据类型是二进制的。
eight 关闭 客户端或服务器正在向服务器或客户端发送结束握手。
nine 客户端或服务器向服务器或客户端发送 ping 命令(参见第八章了解更多关于使用 ping 和 pong 的详细信息)。
10(十六进制 0xA) 恶臭 客户端或服务器向服务器或客户端发送一个 pong(参见第八章关于使用 ping 和 pong 的更多细节)。

由于操作码使用四位,因此最多可以有 16 个不同的值。WebSocket 协议只定义了五个操作码,其余的操作码都是预留给将来在扩展中使用的。

长度

WebSocket 协议使用可变位数对帧长度进行编码,这允许小消息使用紧凑的编码,同时仍然允许协议承载中等大小甚至非常大的消息。对于 126 字节以下的消息,长度被打包成头两个字节中的一个。对于 126 和 216 之间的长度,使用两个额外的字节。对于大于 126 字节的消息,包括 8 个字节的长度。长度编码在帧头第二个字节的后七位中。该字段中的值 126 和 127 被视为特殊信号,附加字节将跟随该信号以完成编码长度。

解码文本

文本 WebSocket 消息使用 UCS 转换格式(8 位或 UTF-8)进行编码。UTF-8 是 Unicode 的可变长度编码,也向后兼容 7 位 ASCII。UTF-8 也是 WebSocket 文本消息中唯一允许的编码。将编码保持在 UTF-8 可以防止无数“纯文本”格式和协议中不同编码的混杂妨碍互操作性。

在清单 3-4 中,deliverText函数使用 Node.js 中的buffer.toString() API 将传入消息的有效载荷转换为 JavaScript 字符串。UTF-8 是buffer.toString()的默认编码,但为了清晰起见,在此指定。

清单 3-4。【UTF-8】文本编码

case opcodes.TEXT:
   payload = buffer.toString("utf8");

屏蔽

从浏览器向上游发送到服务器的 WebSocket 帧被“屏蔽”以混淆其内容。屏蔽的目的不是防止窃听,而是出于特殊的安全原因,并提高与现有 HTTP 代理的兼容性。参见第七章进一步解释屏蔽旨在防止的跨协议攻击。

帧头第二个字节的第一位表示帧是否被屏蔽;WebSocket 协议要求客户端屏蔽它们发送的每个帧。如果有掩码,它将是帧头的扩展长度部分之后的四个字节。

WebSocket 服务器接收到的每个有效负载在处理之前首先被解除屏蔽。清单 3-5 显示了一个简单的函数,该函数在给定四个屏蔽字节的情况下,对 WebSocket 帧的有效载荷部分进行去屏蔽。

清单 3-5。 揭开有效载荷

var unmask = function(mask_bytes, buffer) {
   var payload = new Buffer(buffer.length);
   for (var i=0; i<buffer.length; i++) {
      payload[i] = mask_bytes[i%4] ^ buffer[i];
      }
   return payload;
}

解除屏蔽后,服务器拥有原始的消息内容:二进制消息可以直接传送,文本消息将被 UTF-8 解码,并作为字符串通过服务器 API 公开。

多帧消息

帧格式中的 fin 位允许多帧消息或部分可用消息的流,这些消息可能是分段的或不完整的。要传输不完整的消息,您可以发送 fin 位设置为零的帧。最后一帧的 fin 位设置为 1,表示消息以该帧的有效载荷结束。

WebSocket 关闭握手

我们在本章的前面已经讨论了 WebSocket 开放式握手。在人际交往中,我们经常在初次见面时握手。有时我们分手时也会握手。本协议的情况也是如此。WebSocket 连接总是从开始握手开始,因为这是初始化对话的唯一方式。在 Internet 和其他不可靠的网络上,连接随时都可能关闭,因此不能说连接总是以关闭握手结束。有时候底层的 TCP 套接字会突然关闭。关闭握手优雅地关闭连接,允许应用区分有意和无意终止的连接。

当 WebSocket 关闭时,正在终止连接的端点可以发送一个数字代码和一个原因字符串来表明它为什么选择关闭套接字。代码和原因用 close 操作码(8)编码在帧的有效载荷中。该代码表示为一个无符号的 16 位整数。原因是一个短的 UTF-8 编码字符串。RFC 6455 定义了几个特定的结束代码。代码 1000–1015 指定用于 WebSocket 连接层。这些代码表示网络或协议中出现了故障。表 3-4 列出了该范围内的代码、它们的描述以及每种代码可能适用的场景。

表 3-4 。定义 WebSocket 关闭代码

密码 描述 何时使用此代码
One thousand 常闭 当您的会话成功完成时发送此代码。
One thousand and one 离开 在关闭连接时发送此代码,因为应用正在离开,并且不希望尝试后续连接。服务器可能正在关闭,或者客户端应用可能正在关闭。
One thousand and two 协议错误 由于协议错误而关闭连接时发送此代码。
One thousand and three 不可接受的数据类型 当您的应用收到它无法处理的意外类型的消息时,发送此代码。
One thousand and four 内向的; 寡言少语的; 矜持的 不要发送此代码。根据 RFC 6455,此状态代码是保留的,将来可能会被定义。
One thousand and five 内向的; 寡言少语的; 矜持的 不要发送此代码。WebSocket API 使用这个代码来表示没有收到代码。
One thousand and six 内向的; 寡言少语的; 矜持的 不要发送此代码。Websocket API 使用此代码来指示连接已异常关闭。
One thousand and seven 无效数据 收到格式与消息类型不匹配的消息后发送此代码。如果一条文本消息包含格式错误的 UTF-8 数据,连接应该用这个代码关闭。
One thousand and eight 邮件违反了策略 当您的应用由于其他代码未涉及的原因而终止连接时,或者当您不想公开消息无法处理的原因时,发送此代码。
One thousand and nine 消息太大 当收到的消息太大,应用无法处理时,发送此代码。(请记住,帧的有效载荷长度最长可达 64 位。即使你有一个大的服务器,一些消息仍然太大。)
One thousand and ten 需要扩展 当您的应用需要一个或多个服务器没有协商的特定扩展时,从客户端(浏览器)发送此代码。
One thousand and eleven 意外情况 当您的应用由于不可预见的原因无法继续处理连接时,请发送此代码。
One thousand and fifteen TLS 失败(保留) 不要发送此代码。WebSocket API 使用此代码在 WebSocket 握手之前指示 TLS 何时失败。

image 第二章描述了 WebSocket API 如何使用关闭代码。有关 WebSocket API 的更多信息,请参见http://www.w3.org/TR/websockets/

其他代码范围保留用于特定目的。表 3-5 列出了 RFC 6455 中定义的四类关闭代码。

表 3-5 。WebSocket 关闭代码范围

密码 描述 何时使用此代码
0-999 禁止 低于 1000 的代码无效,永远不能用于任何目的。
1000-2999 内向的; 寡言少语的; 矜持的 这些代码保留用于 WebSocket 协议本身的扩展和修订版本。按照标准规定使用这些代码。参见表 3-4。
3000-3999 需要注册 这些代码旨在供“库、框架和应用”使用这些代码应在 IANA(互联网号码分配机构)公开注册。
4000-4999 私人的 在您的应用中将这些代码用于自定义目的。因为它们没有注册,所以不要指望它们能被其他 WebSocket 软件广泛理解。

支持其他协议

WebSocket 协议支持更高级别的协议和协议协商。矛盾的是,RFC 6455 将可以与 WebSocket 一起使用的协议称为“子协议”, ,即使它们是更高级的、完全形成的协议。正如我们在第二章中提到的,在本书中,我们通常将可以与 WebSocket 一起使用的协议简称为“协议”,以避免混淆。

在第二章中,我们解释了如何用 WebSocket API 协商更高层的协议。在网络层,这些协议使用Sec-WebSocket-Protocol报头进行协商。协议名是客户端在初始升级请求中发送的头值 :

Sec-WebSocket-Protocol: com.kaazing.echo, example.protocol.name

该报头表示客户端可以使用协议(com.kaazing.echoexample.protocol.name)并且服务器可以选择使用哪个协议。如果您在升级请求中向ws://echo.websocket.org发送此报头,服务器响应将包括以下报头:

Sec-WebSocket-Protocol: com.kaazing.echo

这个响应表明服务器已经选择使用com.kaazing.echo协议。选择协议不会改变 WebSocket 协议本身的语法。相反,这些协议位于 WebSocket 协议之上,为框架和应用提供更高级别的语义。在接下来的章节中,我们将研究在 WebSocket 上分层广泛使用的、基于标准的协议的三种不同的用例。

为了简单地扩展 WebSocket 协议,还有另一种机制,称为扩展。

扩展名

像协议一样,扩展是用一个Sec-头协商的。连接客户端发送包含它支持的扩展名称的a Sec-WebSocket-Extensions头。

image 注意虽然您不能一次协商多个协议,但您可以一次协商多个扩展。

例如,Chrome 可能会发送下面的头来表明它将接受一个实验性的压缩扩展:

Sec-WebSocket-Extensions: x-webkit-deflate-frame

扩展如此命名是因为它们扩展了 WebSocket 协议。扩展可以向成帧格式添加新的操作码和数据字段。您可能会发现部署新的扩展比部署新的协议(或“子协议”)更困难,因为浏览器供应商必须显式地构建对这些扩展的支持。您可能会发现,编写一个实现协议的 JavaScript 库比等待所有浏览器供应商标准化一个扩展和所有用户更新他们的浏览器到支持该扩展的版本要容易得多。

用 Node.js 用 JavaScript 写一个 WebSocket 服务器

既然我们已经研究了 WebSocket 协议的要点,那么让我们一步一步地编写我们自己的 WebSocket 服务器。WebSocket 协议有许多现有的实现;您可以选择在应用中使用现有的实现。但是,出于必要或者仅仅因为可以,您可能需要编写一个新的服务器或者修改一个现有的服务器。编写自己的 WebSocket 协议实现既有趣又有启发性,并且可以帮助您理解和评估其他服务器、客户端和库。最重要的是,它能让你对网络、交流和网络有新的认识。

本章中的示例服务器是使用 Node.js 提供的 IO API 用 JavaScript 编写的。我们选择这些技术只是为了将本书中的代码示例限制为单一语言。因为您很可能在前端开发中使用 JavaScript 和 HTML5,所以您也很有可能能够流利地阅读这些代码。当然,您没有必要用 JavaScript 编写您的服务器,并且有充分的理由选择另一种语言。WebSocket 是一种语言无关的协议,这意味着您可以选择任何能够监听套接字的编程语言来创建服务器。

我们编写这个例子是为了使用 Node.js 0.8。它不能在 Node.js 的早期版本上运行,如果节点 API 发生变化,将来可能需要进行一些修改。websocket-example模块将前面的代码片段和一些额外的代码组合起来,形成一个 WebSocket 服务器。这个例子并不完全健壮,也不适合生产,但它确实是该协议的一个简单、独立的例子。

image 注意要构建(或简单地遵循)本书中的示例,您可以选择使用我们创建的虚拟机(VM ),它包含了我们在示例中使用的所有代码、库和服务器。关于如何下载、安装和启动虚拟机的说明,请参考附录 B 。

构建简单的 WebSocket 服务器

清单 3-6 让我们从构建一个简单的 WebSocket 服务器开始。您也可以打开文件websocket-example.js来查看示例代码。

清单 3-6。 用 Node.js 用 JavaScript 写的 WebSocket 服务器 API

// The Definitive Guide to HTML5 WebSocket
//  Example WebSocket server

// See The WebSocket Protocol for the official specification
//http://tools.ietf.org/html/rfc6455

var events = require("events");
var http = require("http");
var crypto = require("crypto");
var util = require("util");

// opcodes for WebSocket frames
//http://tools.ietf.org/html/rfc6455#section-5.2

var opcodes = { TEXT  : 1
   , BINARY: 2
   , CLOSE : 8
   , PING  : 9
   , PONG  : 10
};

var WebSocketConnection = function(req, socket, upgradeHead) {
   var self = this;

   var key = hashWebSocketKey(req.headers["sec-websocket-key"]);

   // handshake response
   //http://tools.ietf.org/html/rfc6455#section-4.2.2

   socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
      'Upgrade: WebSocket\r\n' +
      'Connection: Upgrade\r\n' +
      'sec-websocket-accept: ' + key +
      '\r\n\r\n');

   socket.on("data", function(buf) {
      self.buffer = Buffer.concat([self.buffer, buf]);
      while(self._processBuffer()) {
      // process buffer while it contains complete frames
      }
   });

   socket.on("close", function(had_error) {
      if (!self.closed) {
         self.emit("close", 1006);
         self.closed = true;
      }
   });

   // initialize connection state

   this.socket = socket;
   this.buffer = new Buffer(0);
   this.closed = false;
}
util.inherits(WebSocketConnection, events.EventEmitter);

// Send a text or binary message on the WebSocket connection

WebSocketConnection.prototype.send = function(obj) {
   var opcode;
   var payload;
   if (Buffer.isBuffer(obj)) {
      opcode = opcodes.BINARY;
      payload = obj;
   } else if (typeof obj == "string") {
   opcode = opcodes.TEXT;
// create a new buffer containing the UTF-8 encoded string
   payload = new Buffer(obj, "utf8");
   } else {
      throw new Error("Cannot send object. Must be string or Buffer");
   }
   this._doSend(opcode, payload);
}

// Close the WebSocket connection

WebSocketConnection.prototype.close = function(code, reason) {
   var opcode = opcodes.CLOSE;
   var buffer;

// Encode close and reason

if (code) {
   buffer = new Buffer(Buffer.byteLength(reason) + 2);
   buffer.writeUInt16BE(code, 0);
   buffer.write(reason, 2);
   } else {
      buffer = new Buffer(0);
   }
   this._doSend(opcode, buffer);
   this.closed = true;
}

// Process incoming bytes

WebSocketConnection.prototype._processBuffer = function() {
   var buf = this.buffer;

   if (buf.length < 2) {
      // insufficient data read
      return;
   }

   var idx = 2;

   var b1 = buf.readUInt8(0);
   var fin = b1 & 0x80;
   var opcode =  b1 & 0x0f;      // low four bits
   var b2 = buf.readUInt8(1);
   var mask = b2 & 0x80;
   var length = b2 & 0x7f;      // low 7 bits

   if (length > 125) {
      if (buf.length < 8) {
      // insufficient data read
      return;
   }

   if (length == 126) {
      length = buf.readUInt16BE(2);
      idx += 2;
      } else if (length == 127) {
      // discard high 4 bits because this server cannot handle huge lengths
      var highBits = buf.readUInt32BE(2);
      if (highBits != 0) {
      this.close(1009, "");
      }
      length = buf.readUInt32BE(6);
      idx += 8;
      }
   }

   if (buf.length < idx + 4 + length) {
      // insufficient data read
      return;
   }

   maskBytes = buf.slice(idx, idx+4);
   idx += 4;
   var payload = buf.slice(idx, idx+length);
   payload = unmask(maskBytes, payload);
   this._handleFrame(opcode, payload);

   this.buffer = buf.slice(idx+length);
   return true;
}

WebSocketConnection.prototype._handleFrame = function(opcode, buffer) {
var payload;
switch (opcode) {
   case opcodes.TEXT:
   payload = buffer.toString("utf8");
  this.emit("data", opcode, payload);
   break;
   case opcodes.BINARY:
   payload = buffer;
   this.emit("data", opcode, payload);
   break;
   case opcodes.PING:
   // Respond to pings with pongs
   this._doSend(opcodes.PONG, buffer);
   break;
   case opcodes.PONG:
   // Ignore pongs
    break;
    case opcodes.CLOSE:
   // Parse close and reason
   var code, reason;
   if (buffer.length >= 2) {
   code = buffer.readUInt16BE(0);
   reason = buffer.toString("utf8",2);
    }
    this.close(code, reason);
   this.emit("close", code, reason);
    break;
    default:
    this.close(1002, "unknown opcode");
    }
}

// Format and send a WebSocket message

WebSocketConnection.prototype._doSend = function(opcode, payload) {
   this.socket.write(encodeMessage(opcode, payload));
}

var KEY_SUFFIX = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
var hashWebSocketKey = function(key) {
   var sha1 = crypto.createHash("sha1");
   sha1.update(key+KEY_SUFFIX, "ascii");
   return sha1.digest("base64");
}

var unmask = function(maskBytes, data) {
   var payload = new Buffer(data.length);
   for (var i=0; i<data.length; i++) {
      payload[i] = maskBytes[i%4] ^ data[i];
   }
   return payload;
}

var encodeMessage = function(opcode, payload) {
   var buf;
   // first byte: fin and opcode
   var b1 = 0x80 | opcode;
   // always send message as one frame (fin)

// Second byte: mask and length part 1
// Followed by 0, 2, or 8 additional bytes of continued length
var b2 = 0; // server does not mask frames
var length = payload.length;
if (length<126) {
   buf = new Buffer(payload.length + 2 + 0);
   // zero extra bytes
   b2 |= length;
   buf.writeUInt8(b1, 0);
   buf.writeUInt8(b2, 1);
   payload.copy(buf, 2);
} else if (length<(1<<16)) {
   buf = new Buffer(payload.length + 2 + 2);
   // two bytes extra
   b2 |= 126;
   buf.writeUInt8(b1, 0);
   buf.writeUInt8(b2, 1);
   // add two byte length
   buf.writeUInt16BE(length, 2);
   payload.copy(buf, 4);
} else {
   buf = new Buffer(payload.length + 2 + 8);
   // eight bytes extra
   b2 |= 127;
   buf.writeUInt8(b1, 0);
   buf.writeUInt8(b2, 1);
   // add eight byte length
   // note: this implementation cannot handle lengths greater than 2³²
   // the 32 bit length is prefixed with 0x0000
   buf.writeUInt32BE(0, 2);
   buf.writeUInt32BE(length, 6);
   payload.copy(buf, 10);
 }
return buf;
}

exports.listen = function(port, host, connectionHandler) {
   var srv = http.createServer(function(req, res) {
});

srv.on('upgrade', function(req, socket, upgradeHead) {
   var ws = new WebSocketConnection(req, socket, upgradeHead);
   connectionHandler(ws);
});

srv.listen(port, host);
};

测试我们的简单 WebSocket 服务器

现在,让我们测试我们的服务器。Echo 是网络的“Hello,World ”,所以我们用新的服务器 API 做的第一件事是创建一个服务器,如清单 3-7 所示。回显服务器简单地用连接的客户机发送的任何东西来响应。在这种情况下,我们的 WebSocket echo 服务器将使用它接收到的任何 WebSocket 消息进行响应。

清单 3-7。 使用新的服务器 API 构建 Echo 服务器

var websocket = require("./websocket-example");

websocket.listen(9999, "localhost", function(conn) {
   console.log("connection opened");

   conn.on("data", function(opcode, data) {
      console.log("message: ", data);
      conn.send(data);
   });

   conn.on("close", function(code, reason) {
      console.log("connection closed: ", code, reason);
   });
});

您可以在命令行上使用 node 启动该服务器。确保websocket-example.js在同一个目录中(或者作为一个模块安装)。

> node echo.js

如果您随后从浏览器将一个 WebSocket 连接到这个 echo 服务器,您将会看到您从客户端发送的每一条消息都被服务器回显。

image 注意当你的服务器监听本地主机时,浏览器必须在同一台机器上。您也可以使用第二章中的 Echo 客户端示例来尝试一下。

构建远程 JavaScript 控制台

JavaScript 最好的一个方面是它非常适合交互式开发。Chrome 开发工具和 Firebug 中内置的控制台是 JavaScript 开发如此高效的原因之一。控制台,也称为“真实求值打印循环”的 REPL,允许您输入表达式并查看结果。我们将利用 Node.js repl模块并添加一个定制的eval()函数。通过添加 WebSocket,我们可以通过互联网远程控制 web 应用!有了这个基于 WebSocket 的控制台,我们将能够从命令行界面远程评估表达式。更好的是,我们可以输入一个表达式,并查看对并发连接的每个客户端评估该表达式的结果。

在这个例子中,您将使用在清单 3-6 中显示的同一个服务器,然后构建两个小片段:一个作为远程控制,另一个作为您控制的对象。图 3-8 显示了你将在下一个例子中构建什么。

9781430247401_Fig03-08.jpg

图 3-8 。远程 JavaScript 控制台

在构建这个例子之前,确保你已经构建了清单 3-6 中的例子。如果您还构建了 Echo 服务器部分(清单 3-7 ,您需要在测试随后的代码片段之前关闭 Echo 服务器。清单 3-8 包含了远程控制的 JavaScript 代码。

清单 3-8。web socket-repl . js

var websocket = require("./websocket-example");
var repl = require("repl");

var connections = Object.create(null);

var remoteMultiEval = function(cmd, context, filename, callback) {
   for (var c in connections) {
   connections[c].send(cmd);
   }
   callback(null, "(result pending)");
}

websocket.listen(9999, "localhost", function(conn) {
   conn.id = Math.random().toString().substr(2);
   connections[conn.id] = conn;
   console.log("new connection: " + conn.id);

   conn.on("data", function(opcode, data) {
   console.log("\t" + conn.id + ":\t" + data);
   });
   conn.on("close", function() {
   // remove connection
   delete connections[conn.id];
   });
});

repl.start({"eval": remoteMultiEval});

我们还需要一个简单的网页来控制。这个页面上的脚本简单地打开一个到我们的控制服务器的 WebSocket,评估它接收到的任何消息,并以结果作为响应。客户端还将传入的表达式记录到 JavaScript 控制台。如果你打开浏览器的开发者工具,你会看到这些表达式。清单 3-9 显示了包含脚本的网页。

清单 3-9。

<!doctype html>
<title>WebSocket REPL Client</title>
<meta charset="utf-8">
<script>
var url = "ws://localhost:9999/repl";
var ws = new WebSocket(url);
ws.onmessage = function(e) {
   console.log("command: ", e.data);
   try {
      var result = eval(e.data);
      ws.send(result.toString());
   } catch (err) {
      ws.send(err.toString());
   }
}
</script>

现在如果你运行node websocket-repl.js,你会看到一个交互式解释器。如果你在几个浏览器中加载repl-client.html,你会看到每个浏览器都在评估你的命令。清单 3-10 显示了两个表达式navigator.userAgent5+5的输出。

清单 3-10。 表情从控制台输出

> new connection: 5206121257506311
new connection: 6689629901666194
navigator.userAgent
'(result pending)'
>     5206121257506311:    Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:13.0) Gecko/20100101 Firefox/13.0.1
   6689629901666194:    Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.15 Safari/537.1
5+5
'(result pending)'
>     6689629901666194:    10
   5206121257506311:    10

建议的扩展名

远程 JavaScript 控制台是一些有趣项目的良好起点。下面是扩展这个例子的几种方法:

  • 为远程控制台创建一个 HTML5 用户界面。使用 WebSocket 在用户界面和控制服务器之间进行通信。考虑一下,与使用 AJAX 的 HTTP 等通信策略相比,使用套接字如何简化发送流水线命令和接收延迟响应。
  • 一旦你阅读了第五章,修改远程控制服务器来使用 STOMP。您可以使用主题向每个连接的浏览器会话广播命令,并在队列中接收回复。考虑如何将远程控制服务等新功能融入到消息驱动的应用中。

摘要

在这一章中,我们探讨了互联网和协议的简史,以及为什么会创建 WebSocket 协议。我们详细研究了 WebSocket 协议,包括线路流量、开始和结束握手以及帧格式。我们使用 Node.js 构建了一个示例 WebSocket 服务器,为一个简单的 echo 演示和一个远程控制台提供支持。虽然这一章很好地概述了 WebSocket 协议,但是您可以在这里阅读完整的协议规范:http://tools.ietf.org/html/rfc6455

在接下来的章节中,我们将在 WebSocket 上使用更高级的协议来构建功能丰富的实时应用。

四、使用 XMPP 通过 WebSocket 构建即时消息和聊天

聊天是一个很好的例子,在一个只有 HTTP 的世界里,互联网应用变得越来越难构建。聊天和即时消息应用本质上是异步的:任何一方都可以随意发送消息,而不需要特定的请求或响应。这些应用是 WebSocket 的优秀用例,因为它们极大地受益于减少的延迟。当你与朋友和同事聊天时,你希望尽可能少的延迟,以便进行自然的对话。毕竟,如果有大量的延迟,它就很难成为即时消息。

即时通讯非常适合 WebSocket 聊天应用是这种技术的常见演示和例子。最常见的例子是使用简单的定制消息,而不是标准协议。在这一章中,我们将比这些基本演示更深入地探究,使用一个成熟的协议来挖掘大量不同的服务器实现、强大的功能以及经过验证的可伸缩性和可扩展性。

首先,我们探索 WebSocket 的分层协议,以及在构建使用 WebSocket 上的更高级协议的应用之前需要做出的一些关键选择。在这个例子中,我们使用 XMPP,它代表可扩展消息和存在协议,并且是在即时消息应用中广泛使用的标准。我们通过在 WebSocket 传输层上使用该协议来利用该协议进行通信。在我们的示例中,我们使用 XMPP over WebSocket 逐步将 web 应用连接到 Jabber Instant Messaging (IM) 网络,包括添加指示用户状态和在线状态的功能。

分层协议

在第三章中,我们讨论了 WebSocket 协议的简单演示,包括直接在 WebSocket 层发送和接收消息。我们的远程控制控制台示例演示了使用 WebSocket 构建涉及双向通信的简单应用是可能的。想象一下,扩展像遥控器这样的简单演示来构建更全功能的应用,如聊天客户端和服务器。WebSocket 的一个伟大之处在于,您可以在 WebSocket 的基础上构建其他协议,从而通过 Web 扩展您的应用。让我们来看看 WebSocket 上的分层协议。

图 4-1 显示了 TCP 上互联网应用层协议的典型分层。应用使用 XMPP 或 STOMP (简单的面向文本的消息协议 ??,我们将在第五章中讨论)之类的协议在客户端和服务器之间进行通信。XMPP 和 STOMP 依次通过 TCP 进行通信。使用加密通信时,应用层协议位于 TLS(或 SSL)之上,而 TLS 又位于 TCP 之上。

9781430247401_Fig04-01.jpg

图 4-1 。互联网应用层图表

WebSocket 对世界的看法大同小异。图 4-2 显示了一个类似的图表,WebSocket 作为应用层协议和 TCP 之间的附加层被插入。XMPP 和 STOMP 分层在 WebSocket 之上,web socket 分层在 TCP 之上。在加密的情况下,使用wss://方案的安全 WebSocket 通信是通过 TLS 连接执行的。WebSocket 传输层是一个相对较薄的层,它使 web 应用能够建立全双工网络连接。WebSocket 层可以像图 4-1 中的 TCP 层一样处理,并用于所有相同的协议。

9781430247401_Fig04-02.jpg

图 4-2 。Web 应用层图

图 4-2 包含 HTTP 有两个原因。首先,它说明了 HTTP 是作为 TCP 之上的应用层协议而存在的,可以直接在 web 应用中使用。AJAX 应用使用 HTTP 作为所有网络交互的主要或唯一协议。二、图 4-2 说明使用 WebSocket 的应用不需要完全忽略 HTTP。静态资源几乎总是通过 HTTP 加载。例如,即使您选择使用 WebSocket 进行通信,组成用户界面的 HTML、JavaScript 和 CSS 仍然可以通过 HTTP 提供服务。因此,在您的应用协议栈中,您可以通过 TLS 和 TCP 同时使用 HTTP 和 WebSocket。

当用作标准应用级协议的传输层时,WebSocket 确实大放异彩。这样做,您可以获得标准协议的惊人好处以及 WebSocket 的强大功能。让我们通过研究广泛使用的标准聊天协议 XMPP 来看看这些好处。

XMPP:一英里的 XML 流

您很有可能已经阅读和编写了 XML(可扩展标记语言)。XML 是基于尖括号的标记语言的悠久遗产的一部分,可以追溯到几十年前的 SGML、HTML 和它们的祖先。万维网联盟(W3C)发布了它的语法,许多 Web 技术都使用它。事实上,在 HTML5 之前,XHTML 是 HTML4 的继任者。XML 中的 X 代表可扩展,XMPP 利用了它提供的可扩展性。扩展 XMPP 意味着使用 XML 的扩展机制来创建名称空间,称为 xep(XMPP 扩展协议)。在http://xmpp.org有一个庞大的 xep 库。

XML 是一种文档格式;XMPP 是一种协议。那么,XMPP 是如何使用文档语法进行实时通信的呢?实现这一点的一种方法是在一个单独的文档中发送每条消息。然而,这种方法会不必要的冗长和浪费。另一种方法是将对话视为一个增长久而久之的长文档并传输消息,这就是 XMPP 处理文档语法的方式。XMPP 连接期间发生的双向会话的每个方向都由一个流式 XML 文档表示,该文档在连接终止时结束。该流式文档的根节点是一个<stream/>元素。流的顶层孩子是协议的单个数据单元,称为。一个典型的小节可能看起来像清单 4-1 中的,去掉了空格以节省带宽。

清单 4-1。 XMPP 节

<message type="chat" to="desktopuser@localhost">
<body>
  I like chatting. I also like angle brackets.
</body>
</message>

标准化

今天,您可以在 WebSocket 上使用 XMPP(XMPP/WS ),尽管没有这样做的标准。在工作和时间之后,IETF 有一个规范草案,也许有一天会激发出一个标准。还有几种 XMPP/WS 的实现,其中一些比另一些更具实验性。

WebSocket 上的 XMPP 标准将使独立的服务器和客户端实现以更高的成功概率进行互操作,并将确定将 XMPP 通信绑定到 WebSocket 传输层的所有选择。这些选择包括 WebSocket 和 TCP 之间的每个语义差异的选项,以及如何利用消息边界和操作码,如第三章中所讨论的。标准还将为 WebSocket 客户端和服务器能够识别的 WebSocket 握手中的协议头定义一个稳定的子协议名。在试验阶段,您找到或创建的使用 XMPP over WebSocket 的软件可能在这些选择上有所不同。每一种变化都有可能导致期望特定行为的客户机和服务器之间的不兼容。

虽然标准化的好处很多,但我们不需要等待一个完全成熟的标准来构建一个很酷的应用。我们可以选择一台客户机和一台服务器,我们知道它们可以很好地协同工作。例如,ejabberd-websockets 模块捆绑了一个 JavaScript 客户端库,它实现了 WebSocket 上 XMPP 的草案提案。或者,Kaazing WebSocket Gateway 是一个网关(服务器),包含一套兼容的客户端。

选择连接策略

使用 WebSocket 连接到 XMPP 服务器有两种方法:修改 XMPP 服务器以接受 WebSocket 连接或使用代理服务器。虽然您可以让 XMPP 服务器通过 WebSocket 接受 XMPP,但是这样做需要更新服务器,如果您不控制服务器操作,这可能是不可能的。像talk.google.comchat.facebook.com这样的公共 XMPP 端点就是这种情况。在这些情况下,你需要根据http://tools.ietf.org/html/draft-moffitt-xmpp-over-websocket-01的规范创建你自己的模块。或者,在写这本书的时候,有一些实验性的模块:ejabberd-websockets 和 Openfire 的支持 WebSocket 的模块。图 4-3 显示了一个客户端连接到一个支持 WebSocket 的 XMPP 服务器。

9781430247401_Fig04-03.jpg

图 4-3 。连接到支持 WebSocket 的 XMPP 服务器

第二种方法是使用一个代理服务器,它接受来自客户端的 WebSocket 连接,并与后端服务器建立相应的 TCP 连接。在这种情况下,后端服务器是标准的 XMPP 服务器,接受 TCP 连接上的 XMPP。Kaazing WebSocket 网关中的 XmppClient 采用了这种网关方法。在这里,应用可以通过 Kaazing 的网关连接到任何 XMPP 服务器,甚至是没有明确支持 WebSocket 的服务器。图 4-4 显示了一个 WebSocket 网关服务器接受 WebSocket 连接并对后端 XMPP 服务器进行相应的 TCP 连接的例子。

9781430247401_Fig04-04.jpg

图 4-4 。通过 WebSocket 代理连接到 XMPP 服务器

节到消息对齐

在选择连接策略时,理解 WebSocket 消息(通常包含一个 WebSocket 框架)如何与 XMPP 节对齐是很重要的,因为这两种方法是不同的。在支持 WebSocket 的 XMPP 服务器的情况下,节被一对一地映射到 WebSocket 消息上。每个 WebSocket 消息只包含一个节,不能有重叠或分段。WebSocket 子协议的 XMPP 草案规定了这种一致性。在网关场景中,节到消息的对齐是不必要的,因为它将 WebSocket 转发给 TCP,反之亦然。TCP 没有消息边界,所以 TCP 流可能被任意分割成 WebSocket 消息。然而,在网关的情况下,客户机必须能够通过理解流式 XML 将字符整理成段。图 4-5 显示了 XMPP over WebSocket 子协议草案提案中描述的节到消息的对齐。关于注册 WebSocket 子协议草案的讨论,参见第二章和第三章。

下图显示了在 XMPP 服务器可以通过 WebSocket 直接与客户机通信的情况下,WebSocket 消息是如何与 XMPP 节对齐的。

9781430247401_Fig04-05.jpg

图 4-5 。节到消息的一致性(XMPP over WebSocket 子协议草案提案)

图 4-6 显示了一个节与信息不一致的例子。该图显示了 WebSocket 消息如何不需要与节对齐,其中代理服务器接受 WebSocket 连接并通过 TCP 连接到后端 XMPP 服务器。

9781430247401_Fig04-06.jpg

图 4-6 。没有节到消息的对齐(WebSocket 到 TCP 代理

联合会

许多即时通讯网络是有围墙的花园。拥有特定网络帐户的用户只能相互聊天。相反,Jabber ( http://www.jabber.org)是联合的,这意味着独立运行的服务器上的用户可以在服务器合作的情况下进行通信。Jabber 网络由不同域上的数千个服务器和数百万个用户组成。为联合配置服务器超出了本书的范围。在这里,我们着重于将客户机连接到一台服务器。您可以稍后将您的服务器连接到更大的联邦世界。

通过 WebSocket 构建聊天和即时消息应用

既然我们已经了解了在 WebSocket 上使用 XMPP 背后的一些重要概念,那么让我们来看一个工作示例,并深入研究更实际的细节。这里,我们将使用支持 WebSocket 的 XMPP 服务器,并构建一个典型的聊天应用,该应用通过 WebSocket 使用 XMPP 与服务器进行通信。

使用支持 WebSocket 的 XMPP 服务器

为了构建和运行本章的示例聊天应用,您需要一个支持 WebSocket 的 XMPP 聊天服务器,它与客户端库兼容。正如我们提到的,在撰写本书时,有几个选项,包括 ejabberd-websockets,一个 Openfire 模块,以及一个名为 node-xmpp-bosh 的代理,它理解 WebSocket 协议,这是一个由 Dhruv Matan 构建的开源项目。由于这些模块的实验性质,您的收获可能会有所不同。然而,这些模块正在被快速开发,在这本书出版(或你的阅读)之前,你可能会有许多可靠的选择。

image 注意对于这个前沿例子,我们选择 Strophe.js 作为客户端库。要自己构建这个示例,请选择一个支持 WebSocket 的 XMPP 服务器(或者更新您自己的 XMPP 服务器),并确保它与 Strophe.js 兼容。或者,如前所述,要构建(甚至遵循)本书中的示例,您可以使用我们创建的虚拟机(VM ),它包含我们在示例中使用的所有代码、库和服务器。关于如何下载、安装和启动虚拟机的说明,请参考附录 B 。由于本章中使用的技术的实验性质以及出于学习目的,我们强烈建议您使用我们提供的 VM。

设置测试用户

为了测试您的聊天应用,您需要一个至少有两个用户的消息传递网络来演示交互。为此,在支持 WebSocket 的聊天服务器上创建一对用户。然后,您可以使用这些测试用户使用您将在本章中构建的应用来来回回地聊天。

为了确保您的服务器配置正确,请尝试连接两个桌面 XMPP 客户端。例如,您可以安装以下任意两个客户端:Pidgin、Psi、Spark、Adium 或 iChat。你可以在http://xmpp.org找到更多信息。很可能你已经安装了一两个。在第一个聊天客户端中,您应该看到第二个用户的在线状态。同样,您应该在第二个客户机中看到第一个用户的状态。让其中一个用户保持登录状态,这样您就可以在开发 WebSocket 聊天应用时对其进行测试。

客户端库 : Strophe.js

要使您的聊天应用能够通过 WebSocket 使用 XMPP 与您的聊天服务器进行通信,您需要一个客户端库,使客户端能够与 XMPP 进行交互。在这个例子中,我们使用 Strophe.js,这是一个可以在 web 浏览器中运行的 JavaScript 的开源 XMPP 客户端库。js 提供了一个与 XMPP 交互的底层 API,并包含了构造、发送和接收节的函数。要构建像聊天客户端这样的高级抽象,您需要一些 XMPP 知识。然而,Strophe.js 具有天然的可扩展性,并为使用该库的开发人员提供了精确的控制。

在写这本书的时候,Strophe.js 的稳定分支使用了一个叫做 BOSH 的通信层。在 XEP-0124 扩展中指定的 BOSH 代表同步 HTTP 上的双向流。这是一种特定于 XMPP 的方式,通过半双工 HTTP 实现双向通信,类似于第一章中提到的 Comet 技术。BOSH 比 WebSocket 更早,是出于解决 HTTP 的局限性的类似需求而开发的。

WEBSOCKET,不是 BOSH

ejabberd-websocket 自述文件将 websocket 上的 XMPP 称为“对 Bosh 更优雅、更现代、更快速的替代”当然,现在 WebSocket 已经被标准化,并且即将被普遍部署,类似 Comet 的通信技术很快就会过时。

关于 WebSocket 仿真的讨论,请参见第八章,其中讨论了如何将 WebSocket 与没有本地支持的技术一起使用。

连接和开始使用

在开始聊天之前,您需要将客户端连接到 XMPP/WS 服务器。在这一步中,我们将建立一个从运行在 web 浏览器中的 HTML5 客户端应用到支持 WebSocket 的 XMPP 服务器的连接。一旦连接,套接字将在会话期间在客户机和服务器之间来回发送 XMPP 节。

首先,创建一个名为chat.html的新文件,如清单 4-2 所示。应用的 HTML 部分只是一个基本页面,包括 Strophe.js 库和组成聊天应用的 JavaScript。

清单 4-2。 聊天. html

<!DOCTYPE html>
<title>WebSocket Chat with XMPP</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="chat.css">
<h1>WebSocket Chat with XMPP</h1>

<!-- connect -->
<div class="panel">
   <input type="text" id="username" placeholder="username">
   <input type="password" id="password" placeholder="password">
   <button id="connectButton">Connect</button>
</div>

<div id="presenceArea" class="panel"></div>
<div id="chatArea" class="panel"></div>
<div id="output"></div>

<!-- scripts -->
<script src="strophe.js"></script>
<script src="chat_app.js"></script>

我们将把这个 HTML 文档与一个小小的 CSS 文件链接起来,为用户界面增加一点风格,如清单 4-3 所示。

清单 4-3。 聊天. css

body {
 font-family: sans-serif;
}

#output {
  border: 2px solid black;
  border-radius: 8px;
  width: 500px;
}
   #output div {
     padding: 10px;
   }
   #output div:nth-child(even) {
     background-color: #ccc;
   }

panel {
 display: block;
 padding: 20px;
 border: 1px solid #ccc;
}

我们将从最小版本的chat_app.js开始,随着我们扩展这个例子的功能,我们将增加它。首先,脚本将简单地用 Strophe.js 连接到 XMPP 服务器,并记录其连接状态。它还使用两个输入值:用户名和密码。这些值用于在建立连接时验证用户。

清单 4-4。chat _ app . js 初始版本

// Log messages to the output area
var output = document.getElementById("output");
function log(message) {
   var line = document.createElement("div");
   line.textContent = message;
   output.appendChild(line);
}

function connectHandler(cond) {
 if (cond == Strophe.Status.CONNECTED) {
   log("connected");
   connection.send($pres());
   }
}

var url = "ws://localhost:5280/";
var connection = null;

var connectButton = document.getElementById("connectButton");
connectButton.onclick = function() {
   var username = document.getElementById("username").value;
   var password = document.getElementById("password").value;
connection = new Strophe.Connection(
   {proto: new Strophe.Websocket(url)});
   connection.connect(username, password, connectHandler);
}

请注意,这个示例要求用户输入他或她的凭证。在生产中,确保凭据不在未加密的情况下通过网络发送是非常重要的。实际上,根本不通过网络发送凭证要好得多。有关使用 WebSocket 加密和认证的信息,请参见第七章。如果您的聊天应用是一个更大的 web 应用套件的一部分,您可能希望使用单点登录机制,特别是如果您正在为一个更大的站点构建一个聊天小部件,或者如果您的用户使用外部凭证进行身份验证。

如果一切按计划进行,您应该看到页面上登录了“connected”。如果是这样,那么您已经成功地使用 XMPP over WebSocket 将用户登录到了聊天服务器。您应该会在之前保持连接的另一个 XMPP 客户端的花名册 UI 中看到连接的用户已经上线(参见图 4-7 )。

9781430247401_Fig04-07.jpg

图 4-7 。从 chat.html 登陆,用洋泾浜语出现在网上。开发人员工具中显示的每个 WebSocket 消息都包含一个 XMPP 节

image 注意connect 处理程序中的$pres()函数调用是必要的,用于指示用户已经在线登录。这些存在更新可以传达更多的细节,我们将在下一节中看到。

存在和状态

现在我们知道我们可以连接用户,让我们看一下跟踪用户的存在和状态。web 用户在桌面用户的联系人列表中看起来在线的方式是由于 XMPP 的在线特性。甚至当你不聊天的时候,在线状态信息也不断地从服务器中推出。当您的联系人在线登录、变为空闲状态或更改其状态文本时,您可能会收到状态更新。

在 XMPP 中,每个用户都有一个存在。存在具有可用性值,由显示标签和状态消息表示。要更改这个存在信息,发送一个存在节,如清单 4-5 所示:

清单 4-5。 在场小节示例

<presence>
<show>chat</show>
   <status>Having a lot of fun with WebSocket</status>
</presence>

让我们为用户添加一个方法来改变他们的状态为chat_app.js(参见清单 4-6 )。首先,我们可以添加一些基本的表单控件来设置状态的在线/离线部分,在 XMPP 的说法中称为show。这些控件将显示为下拉菜单,其中包含四个可用性选项。下拉菜单中的值具有简短的指定名称,如“请勿打扰”的“dnd”我们还会给这些人类可读的标签,如“离开”和“忙碌”

清单 4-6。 存在更新 UI

// Create presence update UI
var presenceArea = document.getElementById("presenceArea");
var sel = document.createElement("select");
var availabilities = ["away", "chat", "dnd", "xa"];
var labels = ["Away", "Available", "Busy", "Gone"];
for (var i=0; i<availabilities.length; i++) {
   var option = document.createElement("option");
   option.value = availabilities[i];
   option.text = labels[i];
   sel.add(option);
}
presenceArea.appendChild(sel);

状态文本是自由格式的,所以我们将使用 input 元素,如清单 4-7 所示。

清单 4-7。 状态文本的输入元素

var statusInput = document.createElement("input");
statusInput.setAttribute("placeholder", "status");
presenceArea.appendChild(statusInput);

最后,我们将添加一个按钮,使得更新被发送到服务器(见清单 4-8 )。函数构建了一个 presence 节。为了更新连接用户的状态,presence 节包含两个子节点:showstatus。尝试一下,注意桌面客户端几乎即时反映了 web 用户的状态。图 4-8 说明了到目前为止的例子。

清单 4-8。 按钮事件发送更新

var statusButton = document.createElement("button");
statusButton.onclick = function() {
   var pres = $pres()
   .c("show").t("away").up()
   .c("status").t(statusInput.value);
   connection.send(pres)
}
presenceArea.appendChild(statusButton);

9781430247401_Fig04-08.jpg

图 4-8 。从浏览器更新在线状态。客户端发送的最新 WebSocket 消息包含客户端的 presence stanza.message

要在我们的 web 应用中查看其他用户的状态更新,我们需要理解传入的状态节。在这个简化的例子中,这些存在更新将被记录为文本。清单 4-9 展示了如何在chat_app.js中做到这一点。在成熟的聊天应用中,在线状态更新通常在聊天对话旁边更新。

清单 4-9。 处理状态更新

function presenceHandler(presence) {
   var from = presence.getAttribute("from");
   var show = "";
   var status = "";
   Strophe.forEachChild(presence, "show", function(elem) {
      show = elem.textContent;
   });
Strophe.forEachChild(presence, "status", function(elem) {
   status = elem.textContent;
});

//
   if (show || status){
      log("[presence] " + from + ":" + status + " " + show);
   }

// indicate that this handler should be called repeatedly
   return true;
}

为了用这个函数处理存在更新,我们用连接对象注册了处理程序(见清单 4-10 )。对addHandler()的调用将把presenceHandler()函数与每个 presence 节关联起来。

清单 4-10。 注册出席处理器

connection.addHandler(presenceHandler, null, "presence", null);

图 4-9 显示了当 websocketuser 使用桌面客户端将他的在线状态更新为“去钓鱼-请勿打扰”时,浏览器客户端会立即显示出来。

9781430247401_Fig04-09.jpg

图 4-9 。在浏览中观察存在变化 r

交换聊天信息

在这里,我们得到了任何即时消息应用的核心:聊天消息。聊天消息被表示为消息节,其类型属性被设置为chat。Strophe.js 连接 API 有一个addHandler()函数,让我们监听与该类型匹配的传入消息节,如清单 4-11 所示。

清单 4-11。 监听传入的“聊天”消息小节

function messageHandler(message) {
   var from = message.getAttribute("from");
   var body = "";
   Strophe.forEachChild(message, "body", function(elem) {
      body = elem.textContent;
});

// Log message if body was present
if (body) {
   log(from + ": " + body);
}

// Indicate that this handler should be called repeatedly
   return true;
}

我们还需要在连接后将这个处理程序与连接关联起来,如清单 4-12 所示。

清单 4-12。 将 addHandler 与连接关联

connection.addHandler(messageHandler, null, "message", "chat");

现在,试着从你的聊天客户端,比如 Pidgin,发送一条消息给网络用户。应该用消息节调用消息处理函数。图 4-10 说明了一个聊天消息交换。

9781430247401_Fig04-10.jpg

图 4-10 。洋泾浜语和 chat.html 语的聊天

要将消息发送回 web 用户,您需要向服务器发送一个消息节。这个消息节必须有一个类型属性"chat"和一个包含实际聊天文本的主体元素,如清单 4-13 所示。

清单 4-13。 向服务器发送消息段

<message type="chat" to="desktopuser@localhost">
<body>

   I like chatting. I also like angle brackets.
</body>
</message>

要用 Strophe.js 构建这个消息,使用$msg builder 函数。创建一个消息节,将类型属性设置为chat,将属性设置为您想与之聊天的用户。在您通过连接发送消息后,其他用户应该会很快收到消息。清单 4-14 显示了这个消息节的一个例子。

清单 4-14。 用 Strophe.js 构建消息

// Create chat UI
var chatArea = document.getElementById("chatArea");
var toJid = document.createElement("input");
toJid.setAttribute("placeholder", "user@server");
chatArea.appendChild(toJid);

var chatBody = document.createElement("input");
chatBody.setAttribute("placeholder", "chat body");
chatArea.appendChild(chatBody);

var sendButton = document.createElement("button");
sendButton.textContent = "Send";
sendButton.onclick = function() {
   var message = $msg({to: toJid.value, type:"chat"})
  .c("body").t(chatBody.value);
 connection.send(message);
}
chatArea.appendChild(sendButton);

现在,你们在聊天。当然,您可以在 web 客户端、桌面客户端或两者的组合之间聊天。这个聊天应用是 HTML5 和 WebSocket 的一个很好的例子,通过与标准网络协议的集成,在 web 浏览器中实现了桌面级的体验。这个 web 应用是桌面客户端的真正对等体。它们都是同一网络中的一流参与者,因为它们理解相同的应用层协议。是的,XMPP 是一个标准协议,即使这个特定的 WebSocket 层还没有标准化。它保留了 XMPP 相对于 TCP 的几乎所有优点,即使是作为草案。

任何数量的 web 和桌面客户端之间的对话都是可能的。相同的用户可以从任一客户端连接。在图 4-11 中,两个用户都在使用 web 客户端。

9781430247401_Fig04-11.jpg

图 4-11 。网络客户之间的对话

乒乒乓乓

根据您的服务器配置,该应用可能会在一段时间后自动断开连接。断开连接可能是因为服务器发送了一个 ping,而客户端没有立即响应一个 pong。在 XMPP 中使用 Pings 和 pongs 的目的与在 WebSocket 中使用的目的相同:保持连接活动并检查连接的健康状况。Pings 和 pongs 使用 iq 节。在 XMPP 中,“iq”代表 info/query,是在异步连接之上执行请求/响应查询的一种方式。阿萍长得像清单 4-15 。

清单 4-15。XMPP 服务器 ping

<iq type="get" id="86-14" from="localhost"
   to="websocketuser@localhost/cc9fd219" >
   <ping />
</iq>

服务器将期待一个带有匹配 ID 的 iq 结果形式的响应(见清单 4-16 )。

清单 4-16。设置客户端响应

<iq type="result" id="86-14" to="localhost"
   from "websocketuser@localhost/cc9fd219" />

为了处理 Strophe.js 中的 pings,我们需要注册一个函数来处理所有带有urn:xmpp:ping名称空间和type="get"的 iq 节(参见清单 4-17 )。和前面的步骤一样,我们通过在 connection 对象上注册一个处理程序来实现这一点。处理程序代码构建适当的响应,并将其发送回服务器。

清单 4-17。 为 iq 节注册一个处理程序

function pingHandler(ping) {
   var pingId = ping.getAttribute("id");
   var from = ping.getAttribute("from");
   var to = ping.getAttribute("to");
   var pong = $iq({type: "result", "to": from, id: pingId, "from": to});
   connection.send(pong);

// Indicate that this handler should be called repeatedly
   return true;
}

清单 4-18 显示了处理程序是如何注册的。

清单 4-18。 注册 addHandler

connection.addHandler(pingHandler, "urn:xmpp:ping", "iq", "get");

已完成的聊天应用

清单 4-19 显示了完整的端到端聊天应用,包括 pings 和 pongs。

清单 4-19。 最终版 chat_app.js

// Log messages to the output area
var output = document.getElementById("output");
function log(message) {
   var line = document.createElement("div");
   line.textContent = message;
   output.appendChild(line);
}

function connectHandler(cond) {
   if (cond == Strophe.Status.CONNECTED) {
      log("connected");
      connection.send($pres());
   }
}

var url = "ws://localhost:5280/";
var connection = null;

var connectButton = document.getElementById("connectButton");
connectButton.onclick = function() {
   var username = document.getElementById("username").value;
   var password = document.getElementById("password").value;
   connection = new Strophe.Connection({proto: new Strophe.Websocket(url)});
   connection.connect(username, password, connectHandler);

// Set up handlers
   connection.addHandler(messageHandler, null, "message", "chat");
   connection.addHandler(presenceHandler, null, "presence", null);
   connection.addHandler(pingHandler, "urn:xmpp:ping", "iq", "get");
}

// Create presence update UI
var presenceArea = document.getElementById("presenceArea");
var sel = document.createElement("select");
var availabilities = ["away", "chat", "dnd", "xa"];
var labels = ["Away", "Available", "Busy", "Gone"];
for (var i=0; i<availabilities.length; i++) {
   var option = document.createElement("option");
   option.value = availabilities[i];
   option.text = labels[i];
   sel.add(option);
}
presenceArea.appendChild(sel);

var statusInput = document.createElement("input");
statusInput.setAttribute("placeholder", "status");
presenceArea.appendChild(statusInput);

var statusButton = document.createElement("button");
statusButton.textContent = "Update Status";
statusButton.onclick = function() {
   var pres = $pres();
      c("show").t(sel.value).up();
      c("status").t(statusInput.value);
   connection.send(pres);
}
presenceArea.appendChild(statusButton);
function presenceHandler(presence) {
   var from = presence.getAttribute("from");
   var show = "";
   var status = "";

Strophe.forEachChild(presence, "show", function(elem) {
   show = elem.textContent;
});

Strophe.forEachChild(presence, "status", function(elem) {
   status = elem.textContent;
});

if (show || status){
   log("[presence] " + from + ":" + status + " " + show);
}

// Indicate that this handler should be called repeatedly
   return true;
}

// Create chat UI
var chatArea = document.getElementById("chatArea");
var toJid = document.createElement("input");
toJid.setAttribute("placeholder", "user@server");
chatArea.appendChild(toJid);

var chatBody = document.createElement("input");
chatBody.setAttribute("placeholder", "chat body");
chatArea.appendChild(chatBody);

var sendButton = document.createElement("button");
sendButton.textContent = "Send";
sendButton.onclick = function() {
   var message = $msg({to: toJid.value, type:"chat"})
   .c("body").t(chatBody.value);
   connection.send(message);
}
chatArea.appendChild(sendButton);

function messageHandler(message) {
   var from = message.getAttribute("from");
   var body = "";
   Strophe.forEachChild(message, "body", function(elem) {
      body = elem.textContent;
});

// Log message if body was present
if (body) {
   log(from + ": " + body);
}

// Indicate that this handler should be called repeatedly
   return true;
}

function pingHandler(ping) {
   var pingId = ping.getAttribute("id");
   var from = ping.getAttribute("from");
   var to = ping.getAttribute("to");

   var pong = $iq({type: "result", "to": from, id: pingId, "from": to});
   connection.send(pong);

// Indicate that this handler should be called repeatedly
   return true;
}

建议的延期

既然我们已经构建了一个基本的基于浏览器的聊天应用,您可以利用这个例子并做许多其他很酷的事情来将它变成一个成熟的应用。

构建用户界面

我们的示例网页chat.html,显然没有最漂亮或最有用的用户界面。考虑增强您的聊天客户端的 UI,加入更多用户友好的功能,如选项卡式对话、自动滚动和可见的联系人列表。将它构建为 web 应用的另一个好处是,您拥有许多强大的工具,可以用 HTML、CSS 和 JavaScript 来实现华丽而灵活的设计。

使用 XMPP 扩展

XMPP 有丰富的扩展生态系统。在http://xmpp.org上有数百个扩展提案或“xep”。这些功能从头像和群聊到 VOIP 会话初始化。

XMPP 是向 web 应用添加社交功能的好方法。对联系人、状态和聊天的内置支持提供了一个社交核心,您可以在此基础上添加协作、社交通知等。很多扩展都有这个目标。其中包括用于微博、评论、头像和发布个人事件流的 xep。

连接到 Google Talk

你可能从 Gmail 和 Google+中熟悉的聊天服务 Google Talk 实际上是 Jabber IM 网络的一部分。有一个可公开访问的 XMPP 服务器在端口5222上监听talk.google.com。如果你有一个 Google 帐户,你可以将任何兼容的 XMPP 客户端指向那个地址并登录。要使用您自己的 web 客户端连接到 Google Talk,请将 WebSocket 代理服务器指向该地址。该服务器需要加密,因此请确保该服务器配置为通过 TLS 进行连接。

摘要

在这一章中,我们探讨了如何在 WebSocket 上分层协议,特别是标准协议,以及 XMPP 这样的标准应用层协议如何适应标准的 web 架构。我们通过 WebSocket 构建了一个简单的聊天客户端,它使用了广泛使用的聊天协议 XMPP。在这样做的过程中,我们看到了使用 WebSocket 作为传输层以及这个标准应用层协议将 web 应用连接到交互式网络的强大功能。

在下一章中,我们将在 WebSocket 上使用 STOMP 来构建一个功能丰富的实时消息应用。

五、通过 STOMP 使用 WebSocket 上的消息传递

在前一章中,我们探讨了在 WebSocket 上分层协议的概念,以及在 WebSocket 上分层基于标准的协议的一些主要好处。具体来说,我们着眼于在 WebSocket 上构建一个标准的即时消息和存在协议 XMPP。在这一章中,我们将研究如何在 WebSocket 中使用消息传递协议。

消息传递是一种架构风格,其特点是在独立组件之间发送异步消息,允许您构建松散耦合的系统。消息传递为通信模式提供了一个抽象层,因此是编写网络应用的一种非常灵活和强大的方式。

消息传递中的关键角色是消息代理和客户端。消息代理接受来自客户端的连接,处理来自客户端的消息,并向客户端发送消息。代理还可以处理身份验证、授权、消息加密、可靠的消息传递、消息节流和扇出等职责。当客户机连接到消息代理时,它们可以向代理发送消息,也可以接收代理发送给它们的消息。这种模型称为发布/订阅,其中消息代理发布大量消息,客户端订阅所有消息,或者更常见的是订阅消息的子集。

消息传递也广泛用于企业中,以集成不同的企业应用。除了松散耦合的企业系统,企业消息传递还关注关键的企业需求,包括加密、单点登录、授权、高可用性和可伸缩性。

类似于我们如何在 WebSocket 上分层 XMPP,您也可以在 WebSocket 上分层发布/订阅协议。发布/订阅(或通俗地说,发布/订阅)协议的一个例子是 STOMP(简单或面向流文本的消息协议)。图 5-1 显示了 STOMP over WebSocket 如何与其他协议的分层相关联。

9781430247401_Fig05-01.jpg

图 5-1 。在 WebSocket 上分层踩踏

WebSocket 非常适合典型的消息传递体系结构,在这种体系结构中,可能有大量的消息以很快的速度从消息代理流向客户端。例如,消息传递的一个典型用例是客户订阅外汇或股票信息;在这种情况下,消息(汇率、股票价值等)非常小,但是客户端实时、低延迟地接收消息对于应用的成功至关重要。基于到目前为止您在本书中学到的东西,您可以看到 WebSocket 是如何非常适合这种应用的。

在这一章中,我们将研究 pub/sub 模型,一个广泛使用的协议(STOMP ),然后逐步构建你自己的 pub/sub 应用——一个游戏!—使用 STOMP over WebSocket。我们将使用流行的开源消息代理 Apache ActiveMQ ,并探索在您自己的架构中使用 STOMP 和 WebSocket 的一些方法。

image 注意STOMP 定义的文本部分表示该协议是面向文本的。第六章关注 RFB 协议,描述了如何在 WebSocket 上使用面向二进制的协议。

发布和订阅模型概述

一种常见的消息传递模式是发布/订阅模式(pub/sub)。在发布/订阅模式中,客户端连接到分发消息的代理。客户端可以向代理发布消息和/或订阅一个或多个消息源。

在消息世界中,有两种常用的消息分发技术,如图 5-2 中的所示:

9781430247401_Fig05-02.jpg

图 5-2 。带主题和队列的消息传递

  • 队列:将消息传递给单个消费者的分发机制。任意数量的客户端(发布者)可以向队列发布消息,但是每个消息都由一个且只有一个客户端(消费者)使用。
  • 主题:向多个消费者传递消息的分发机制。任意数量的客户机(发布者)可以向一个主题发布消息,任意数量的客户机(消费者)可以消费它们。

image 注意不是每个消息代理都使用主题和队列。不过,在本章中,我们使用 Apache ActiveMQ,它支持主题和队列。

使用 WebSocket 的次数越多,就越会意识到构建基于 WebSocket 的应用的需求类似于经典的消息传递概念。例如,您可能希望通过向大量客户端分发大量消息来将企业消息传递协议的范围扩展到 Web。或者,假设您正在构建一个协作应用,该应用要求您的 WebSocket 客户端向其他 WebSocket 客户端发送数据,并从其他 web socket 客户端接收数据。这两个例子说明了消息传递应用和 WebSocket 应用。正如您将在本章中看到的,这两种技术配合得很好,通过 WebSocket 对消息进行分层使您能够构建强大的消息应用。

消息系统在与客户端集成的方式上有所不同。有些,比如支持 STOMP 的代理,提供协议级的互操作性。任何实现兼容协议客户端的人都可以从任何平台和语言连接到这些系统。其他的提供了为系统供应商选择的一些平台提供的 API。

最简单、开放、广泛使用的消息传递协议是 STOMP:简单(或流)面向文本的消息传递协议。企业中使用最广泛的消息传递 API 是 JMS: Java 消息服务。与 STOMP 不同,它通过定义一个有线协议来促进互操作性,JMS 只是一个 API。STOMP 已经为许多不同的语言实现了;由于其 API 的性质,JMS 几乎独占了 Java 世界。

一个新标准化的开放消息协议是 AMQP :高级消息队列协议。AMQP 1.0 于 2012 年 10 月成为绿洲标准。虽然 AMQP 的创建得到了业界的广泛支持,但它是否能像 STOMP 和 JMS 那样受欢迎并取得成功还有待观察。要了解更多关于 AMQP 的信息,请参见http://amqp.org

在本章中,我们将通过 WebSocket 使用 STOMP(STOMP/WS)。但是,如果您对 JMS 或 WebSocket 上的 AMQP 感兴趣,有供应商和项目可以为您提供这些功能。此外,WebSocket 上有几个专有的发布/订阅实现:有些很简单,有些更复杂。请参见附录 B 了解当前可能提供您所需支持的 WebSocket 服务器列表。本章中的步骤也有望帮助您对 WebSocket 上的发布/订阅实现的工作原理有一个总体的了解。

STOMP 简介

STOMP 是一种用于消息传递的开放协议,最初是为了与 Apache ActiveMQ 一起使用而开发的,现已广泛传播到其他系统。STOMP 没有主题或队列。从目的地发送和接收 STOMP 消息;STOMP 服务器决定这些目的地的行为方式。这种行为类似于 HTTP,因为服务器只有 URL,由服务器决定如何服务这些 URL。在本章构建的例子中,我们使用了带有 ActiveMQ 的 STOMP。ActiveMQ 使用目的地名称来公开消息传递特性,包括主题和队列、临时目的地和分层订阅。

关于名字中包含“简单”一词的标准,有一个流传已久的笑话:它们几乎普遍过于复杂。示例包括 SNMP(简单网络管理协议)、SOAP(简单对象访问协议)和 SMTP(简单邮件传输协议)。STOMP 是一个真正简单的 ?? 协议:它面向文本,在外观上类似于 HTTP。每个帧由命令、帧头和帧体组成。STOMP 消息正文可以包含任何文本或二进制数据。清单 5-1 显示了一个包含文本主体的示例SEND框架。这个例子描述了一个空终止的SEND帧,它发送一个消息到一个名为/topic/hello/world的目的地。消息末尾的黑色方块表示 NULL,这是一个不可打印的字符。

清单 5-1。 一个空终止发送帧

SEND

destination: /topic/hello/world

content-type: text/plain

hello, world!

image

content-length 标头传达了帧体的大小。这个标题是可选的。没有内容长度头的消息以空(0x00)字节结束,以标记其正文内容的结束。以这种方式终止的消息在其有效负载中间不能包含空字节。

image 注意STOMP 帧的语法由您可以在应用中使用的客户端和服务器软件处理,但是如果您想开发自己的实现,可以参考http://stomp.github.com中的规范。

您可以像任何 TCP 级协议一样在 WebSocket 上分层 STOMP,或者对齐它,使每个 STOMP 帧正好占用一个 WebSocket 帧。

网络信息入门

现在我们已经研究了消息传递的概念,以及 WebSocket 可以给 STOMP 这样的消息传递协议带来的一些好处,让我们构建一个消息传递应用的工作示例,它使用 STOMP over WebSocket 通过消息代理向客户端传递消息。

在这个例子中,我们将使用广泛可用的开源消息代理 Apache ActiveMQ,它支持 WebSocket。我们将逐步配置 ActiveMQ 来接受 WebSocket 连接,允许我们使用 STOMP over WebSocket 进行通信。ActiveMQ 还方便地包含了开箱即用的演示,我们将使用它来浏览我们已经讨论过的一些概念。然后,我们将构建自己的 STOMP/WS 应用。你可以在http://activemq.apache.org了解更多关于 ActiveMQ 的信息。

image 注意为了构建(甚至跟随)本书中的例子,你可以选择使用我们已经创建的虚拟机(VM ),它包含了我们在例子中使用的所有代码、库和服务器。关于如何下载、安装和启动虚拟机的说明,请参考附录 B 。

设置消息代理

首先,从http://activemq.apache.org/download.html下载消息代理。在写这本书的时候,最新的可用 ActiveMQ 版本是 5.7,支持 STOMP 1.1,但是更新的版本应该也可以。

image 注意ActiveMQ 下载有两种版本:一种用于 Windows ( *.zip),一种用于各种 Unix 版本:Linux、Unix 和 Mac ( *.tar.gz)。

下载并解压 ActiveMQ 后,你的目录结构应该类似于图 5-3 所示。

9781430247401_Fig05-03.jpg

图 5-3 。安装后的 ActiveMQ 主目录

要启动 ActiveMQ,请打开终端并导航到 ActiveMQ 主目录中的bin目录,这是您提取 ActiveMQ 的目录。运行清单 5-2 中的命令。

清单 5-2。 启动 Apache ActiveMQ

$> ./activemq console

成功启动 ActiveMQ 后,可以在http://0.0.0.0:8161打开浏览器,导航到欢迎页面,如图图 5-4 所示。

9781430247401_Fig05-04.jpg

图 5-4 。Apache ActiveMQ 欢迎页面

欢迎页面列出了有用的链接。第一个链接 Manage ActiveMQ broker 将您带到 ActiveMQ 管理控制台,我们将在后面更深入地描述它。第二个链接“查看一些 Web 演示”会将您带到产品附带的演示的启动页面。单击查看一些 Web 演示链接,或者简单地将/demo附加到 URL: http://0.0.0.0:8161/demo

列表中的第一个演示是 WebSocket 示例。为了让这个演示工作,我们需要配置实现 STOMP over 的 WebSocket 传输。在您的终端中,停止 ActiveMQ 例如,如果您使用清单 5-1 中的命令启动它,只需按 Ctrl+C。一旦您停止了 ActiveMQ,您现在就可以配置消息代理使用 WebSocket。

首先,打开文件ActiveMQ_HOME/conf/activemq.xml,搜索字符串transportConnectors。在 openwire 传输连接器下面,添加清单 5-3 中的代码片段。

清单 5-3。 声明 WebSocket 连接器

<transportConnectors>
<transportConnector name="websocket" uri="ws://0.0.0.0:61614"/>
</transportConnectors>

现在,您的activemq.xml文件 的传输连接器部分应该看起来类似于清单 5-4 (突出显示了新添加的部分)。

清单 5-4。 带有 WebSocket 连接器的 activemq.xml 片段

<!-- The transport connectors expose ActiveMQ over a given
protocol to clients and other brokers. For more
      information, see:
      http://activemq.apache.org/configuring-transports.html-->
<transportConnectors>
   <transportConnector name="openwire" uri="tcp://0.0.0.0:61616"/>
</transportConnectors>
<transportConnectors>
      <transportConnector name="websocket" uri="ws://0.0.0.0:61614"/>
</transportConnectors>

保存activemq.xml文件并再次启动 ActiveMQ,如清单 5-2 所示。通过在控制台中查找清单 5-5 中显示的行,确认 WebSocket 连接器已经启动。

清单 5-5。 表示 WebSocket 连接器已经启动的日志消息

INFO | Connector websocket Started

现在,您已经准备好开始作为 ActiveMQ 的一部分提供的 WebSocket 示例了。导航到http://0.0.0.0:8161/demo,点击 WebSocket 示例链接,或者直接在浏览器地址栏http://0.0.0.0:8161/demo/websockets.html中输入直接 URL 。

图 5-5 显示了在这个网址显示的页面。

9781430247401_Fig05-05.jpg

图 5-5 。Apache ActiveMQ 附带的 WebSocket 演示

image 注意要对演示进行故障排除或了解更多有关使用 ActiveMQ 配置 WebSocket 的信息,请参见http://activemq.apache.org/websockets.html中的产品说明。

看到 STOMP 概念的实际应用

ActiveMQ 的 WebSocket 演示演示了我们在本章前面讨论的一些基本的 STOMP 概念,并提供了一种在我们自己构建应用之前查看它们的简单方法。让我们看看这些概念是如何出现在这个演示中的。

要开始,您必须首先连接到服务器。当我们更新activemq.xml文件时,我们在ws://0.0.0.0:61614/stomp设置了服务器 URL。您现在可以使用此 URL。然后,您需要提供用户凭证:用户名和密码。我们将使用guest作为用户名(在示例中标记为 Login)和密码。最后,必须提供一个队列作为目的地。默认情况下,它被称为测试。前缀/queue表示这是一个队列,/test是队列的名称。随意更改字符串的后半部分,比如:/queue/stompDemo

image 注意为了让演示工作,你只需要改变服务器的 URL。一定要用0.0.0.0而不是localhost。所有其他字段都是为您预先填充的,您不需要更改它们。

点击连接后,显示您的应用,如图图 5-6 所示。

9781430247401_Fig05-06.jpg

图 5-6 。运行 Apache ActiveMQ WebSocket 演示

在图 5-6 中,注意记录了 STOMP 消息。让我们仔细看看这些消息。首先,建立 WebSocket 连接,用提供的凭证打开 STOMP 连接:guest/guest。然后发送心跳消息。成功创建 STOMP 连接后,演示应用订阅了stompDemo队列。

现在,打开第二个浏览器窗口(您可以打开同一浏览器的新窗口或启动不同的浏览器),并提供与上面使用的完全相同的连接数据。此时,您可以开始在浏览器窗口之间来回发送消息。

构建 STOMP over WebSocket 应用

既然我们已经看了一个 STOMP/WS 应用 的简单演示,让我们试着构建一个。在这里,我们逐步构建一个应用,允许用户玩流行的手游戏石头剪刀布,也称为“roshambo”如果你不熟悉这个游戏,维基百科提供了大量关于它的信息:http://en.wikipedia.org/wiki/Rock-paper-scissors

游戏的流程

让我们回顾一下游戏的要求和流程。传统的游戏方式要求参与者同时呼唤他们的物体(石头、布或剪刀)。为了实现这一点,游戏之前有一个“同步阶段”,之后玩家大声说出他们的选择。

在浏览器中玩游戏的好处在于用户可以远程操作,这也意味着这种“同步阶段 ”的工作方式会略有不同。为了模仿在线设置中的“同步阶段”,我们将相互隐藏玩家的选择,直到两个玩家都选择了他们的对象。

以下是基于浏览器的游戏如何在两个玩家之间进行的概述:

  1. 玩家 1(首先移动的玩家)选择一个选项(石头、布或剪刀)。玩家 1 的应用显示此选项。
  2. 玩家 2(速度较慢的玩家)的应用接收到玩家 1 的移动(但不显示选择),并指示玩家 1 已经做出了选择。
  3. 玩家 2 选择一步棋(石头、布或剪刀)。
  4. 玩家 1 的应用接收并显示玩家 2 的选择。
  5. 玩家 2 的应用显示玩家 1 和玩家 2 的选择。

构建这个应用的挑战在于,我们能否让它只在浏览器中运行,而不需要任何后端代码或逻辑。我们如何做到这一点?当然是通过消息和 WebSocket。

首先,我们必须考虑应用如何通信。出于演示的目的,我们将通过两个玩家来构建这个游戏,其中两个应用直接相互通信。你会从图 5-2 中回忆起,我们可以使用队列(向单个消费者传递消息),而不是主题(向多个消费者分发消息)。

为了实现我们的目标,并保持我们的应用相当简单,我们用两个队列构建应用,其中玩家 1 的应用发布到一个队列,玩家 2 的应用从同一个队列消费。然后,玩家 2 的应用发布到第二个队列,玩家 1 的应用使用第二个队列中的消息。

游戏开始前,我们会要求玩家输入他们的名字,以此来识别队列。

基于这些需求,让我们来看看玩家与应用的交互。app 启动时,等待玩家输入姓名,如图图 5-7 所示。

9781430247401_Fig05-07.jpg

图 5-7 。在两个浏览器窗口中并排运行石头剪子布应用

玩家输入自己的名字,点击 Go 按钮,如图图 5-8 所示。

9781430247401_Fig05-08.jpg

图 5-8 。用户在石头剪子布应用中输入他们的名字

一号玩家(彼得)做出选择。该选择反映在界面上,如图图 5-9 所示。在玩家 2(Vanessa)的屏幕上,显示一条消息,向玩家表明对手已经做出选择:你的对手正在等你。行动吧。

9781430247401_Fig05-09.jpg

图 5-9 。一号玩家行动

第二个玩家做出选择后,结果会立即显示给双方玩家,如图图 5-10 所示。

9781430247401_Fig05-10.jpg

图 5-10 。参与人 2 移动

在“真正的”石头剪子布 游戏中,每一轮过后你都要宣布一个赢家。为了保持这个演示的简单和源代码的重点,我们不包括这个特性。

创建游戏

我们的简单应用由我们自己的 HTML 和一个 JavaScript 文件组成,并利用了两个外部开源 JavaScript 资源。第一个 JavaScript 库叫做stomp.js,由 Jeff Mesnil 编写。该库包含在本书的发行版中,但也可以在 GitHub: https://github.com/jmesnil/stomp-websocket上找到。这个库使 JavaScript 应用能够使用我们的支持 WebSocket 的 ActiveMQ 消息代理来说 STOMP/WS。

第二个 JavaScript 库是 jQuery,我们使用它是为了简单,并帮助我们以更简洁的方式编写代码。我们将包含应用逻辑的 HTML 和 JavaScript 文件分别称为index.htmlrps.js

image 我们构建了我们的 app jQuery 版本 1.8.2 。本书的发行版中包含了缩小版的 jQuery 库jquery-1.8.2.min,不过你也可以从 jQuery 官方下载网站:http://jquery.com/download下载。

构建 HTML 文件

在这个例子中,我们保持我们的 HTML 代码简单,以便我们可以专注于应用的消息传递逻辑。在包含了 JavaScript 库之后,我们需要为玩家的名字创建表单域和按钮,如图图 5-11 所示。

9781430247401_Fig05-11.jpg

图 5-11 。为玩家的名字创建表单域

然后,我们为指令创建一个div,为按钮创建另一个div,如图 5-12 中的所示,这允许玩家做出选择。

9781430247401_Fig05-12.jpg

图 5-12 。代表用户选择的按钮

最后,我们有一个空的div来显示对手的选择。清单 5-6 显示了我们的 HTML 文件的源代码。

清单 5-6。index.html 文件的源代码

<!DOCTYPE html><html><head>   <title>Rock Paper Scissors - a WebSocket Demo</title>   <!-- JavaScript libraries used: jQuery and the        open source STOMP library -->   <script src="js/jquery-1.8.2.min.js"></script>   <script src='js/stomp.js'></script>   <script src='js/rps.js'></script></head><body>   <!-- Form fields and button for the players' names.        The queues are named after the users -->    <div id="nameFields">   <input id="myName" type="text" placeholder="Your name"/>
   <input id="opponentName" type="text" placeholder="Opponent's name"/>
   <button id="goBtn" onclick="startGame();">
      Go
   </button>
   </div>
   <!-- Instructions and buttons for the users to make their selections, hidden initially -->
   <div id="instructions" style="visibility:hidden;">
   <p>Select one:</p>
   </div>
   <div id="buttons" style="visibility:hidden;">
   <button id="rockBtn" name="rock" onclick="buttonClicked(this);">
      Rock
   </button>
   <button id="paperBtn" name="paper" onclick="buttonClicked(this);">
      Paper
   </button>
   <button id="scissorsBtn" name="scissors" onclick="buttonClicked(this);">
      Scissors
   </button>
   </div>
   <!-- div to display opponent's choice, initially empty; populated by JavaScript code in rps.js -->
   <div id="opponentsButtons"></div></body></html>

编写游戏代码

现在,我们已经为我们的应用构建了一个简单的用户界面,让我们仔细看看 JavaScript 代码 。首先,我们声明变量,如清单 5-7 所示。请注意,我们将我们的连接 URL 包含到启用 WebSocket 的基于 STOMP 的消息代理 ActiveMQ 中。

清单 5-7。 声明 JavaScript 代码中使用的变量

// ActiveMQ STOMP connection URL
var url = "ws://0.0.0.0:61614/stomp";
// ActiveMQ username and password. Default value is "guest" for both.
var un, pw = "guest";

var client, src, dest;

// Variables holding the state whether the local and
// remote user had his/her turn yet
var hasUserPicked, hasOpponentPicked = false;

// HTML code for the opponent's three buttons and variable
// for opponent's pick
var opponentsBtns = '<button id="opponentRockBtn" name="opponentRock" disabled="disabled">Rock</button>' + '<button id="opponentPaperBtn" name="opponentPaper" disabled="disabled">Paper</button>' + '<button id="opponentScissorsBtn" name="opponentScissors" disabled="disabled">Scissors</button>';
var opponentsPick;

// Variables for this user's three buttons
var rockBtn, paperBtn, scissorsBtn;

在 DOM 层次结构完全构建之后,我们检查浏览器是否支持 WebSocket。如果没有,我们隐藏由 HTML 页面呈现的div,并显示一个警告(清单 5-8 )。

清单 5-8。检查浏览器是否支持 WebSocket

// Testing whether the browser supports WebSocket.
// If it does, fields are rendered for users' names
$(document).ready(function() {
    if (!window.WebSocket) {
        var msg = "Your browser does not have WebSocket support. This example will not work properly.";
        $("#nameFields").css("visibility", "hidden");
        $("#instructions").css("visibility", "visible");
        $("#instructions").html(msg);
    }
});

startGame()函数 由goBtnonclick事件调用。这个函数,如清单 5-9 所示,禁用先前填写的表单的所有元素,使指令和按钮div可见,并为源(src)和目的(dest)队列构造名称。

清单 5-9。startGame()功能

var startGame = function() {
    // Disabling the name input fields
    $("#myName").attr("disabled", "disabled");
    $("#opponentName").attr("disabled", "disabled");
    $("#goBtn").attr("disabled", "disabled");
    // Making the instructions and buttons visible
    $("#instructions").css("visibility", "visible");
    $("#buttons").css("visibility", "visible");
    // Queues are named after the players
    dest = "/queue/" + $("#opponentName").val();
    src = "/queue/" + $("#myName").val();
    connect();
};

清单 5-9 中的最后一个函数调用调用了connect()函数,该函数建立了 STOMP 连接,如清单 5-10 中所示。connect()函数内部的调用由我们使用的开源 STOMP JavaScript 库提供:stomp.js

清单 5-10。 利用 connect()函数,建立 STOMP 连接

// Establishing the connectionvar connect = function() {   client = Stomp.client(url);
   client.connect(un, pw, onconnect, onerror);};

client.connect API 有两个回调函数。第一个是onconnect(),在成功连接时调用;第二个,onerror(),在错误发生时被调用。

我们来仔细看看 onconnect()回调函数。在我们成功连接到控制台的日志之后,我们订阅由src变量定义的队列。这个队列是以这个玩家的名字命名的。每当有消息到达这个队列时,定义为client.subscribe的第二个参数的回调就会被执行。当收到的信息表明对手已经选择了,我们将hasOpponentPicked设置为true。然后,我们画出代表对手玩家选择的按钮,但是如果这个玩家还没有移动,就隐藏它们,如清单 5-11 所示。

清单 5-11。 代码渲染游戏按钮

// Function invoked when connection is established
var onconnect = function() {
    console.log("connected to " + url);
    client.subscribe(src, function(message) {
        console.log("message received: " + message.body);
        // The incoming message indicates that the
        // opponent had his/her turn (picked).
        // Therefore, we draw the buttons for the opponent.
        // If this user hasn't had his/her move yet,
        // we hide the div containing the buttons,
        // and only display them
        // when this user has had his/her move too.
        hasOpponentPicked = true;
        if (!hasUserPicked) {
            $("#opponentsButtons").css("visibility", "hidden");
            $("#instructions").html("<p>Your opponent is waiting for you. Make your move!</p>");
        } else {
            $("#instructions").html("<p>Results:</p>");
            client.disconnect( function() {
                console.log("Disconnected...");
            });
        }
        $("#opponentsButtons").html(opponentsBtns);
        switch (message.body) {
            case "rock"     :
                opponentsPick = "#opponentRockBtn";
                break;
            case "paper"    :
                opponentsPick = "#opponentPaperBtn";
                break;
            case "scissors" :
                opponentsPick = "#opponentScissorsBtn";
                break;
        }
        $(opponentsPick).css("background-color", "yellow");
    });
    console.log("subscribed to " + src);
};

如果出现错误,我们可以使用onerror()回调函数轻松处理,如清单 5-12 所示。测试该函数执行情况的一个简单方法是首先创建一个连接,然后停止 ActiveMQ。这样,您将在控制台上看到一条错误消息,指示连接已丢失。

清单 5-12。 用 onerror 回调函数捕捉错误

var onerror = function(error) {
    console.log(error);
};

当用户从三个选项中选择一个时,我们代码的最后一个函数被调用:石头、布或剪刀。客户端对象的send()函数有三个参数:目的地、头(在我们的例子中为空)和消息(按钮 DOM 对象的名称)。我们将hasUserPicked标志切换到true,表示用户已经选择了。然后,我们禁用表单字段。根据对手是否移动,我们或者显示对手的移动,或者改变指令信息,让这个玩家知道我们在等待对手(清单 5-13 )。

清单 5-13。 向用户选择(石头、布或剪刀)添加交互

// ActiveMQ STOMP connection URL
var url = "ws://0.0.0.0:61614/stomp";
// ActiveMQ username and password. Default value is "guest" for both.
var un, pw = "guest";

var client, src, dest;

// Variables holding the state whether the local and remote user had his/her turn yet
var hasUserPicked, hasOpponentPicked = false;

// HTML code for the opponent's three buttons and
// variable for opponent's pick
var opponentsBtns = '<button id="opponentRockBtn" name="opponentRock"
disabled="disabled">Rock</button>' + '<button id="opponentPaperBtn"
name="opponentPaper" disabled="disabled">Paper</button>' +
'<button id="opponentScissorsBtn" name="opponentScissors"
disabled="disabled">Scissors</button>';
var opponentsPick;

// Variables for this user's three buttons
var rockBtn, paperBtn, scissorsBtn;

// Testing whether the browser supports WebSocket.
// If it does, fields are rendered for users' names
$(document).ready(function() {
   if (!window.WebSocket) {
      var msg = "Your browser does not have WebSocket support. This example will not work properly.";
      $("#nameFields").css("visibility", "hidden");
      $("#instructions").css("visibility", "visible");
      $("#instructions").html(msg);
   }
});

// Getting started with the game. Invoked after
// this user's and opponent's name are submitted
var startGame = function() {
   // Disabling the name input fields
   $("#myName").attr("disabled", "disabled");
   $("#opponentName").attr("disabled", "disabled");
   $("#goBtn").attr("disabled", "disabled");
   // Making the instructions and buttons visible
   $("#instructions").css("visibility", "visible");
   $("#buttons").css("visibility", "visible");
   // Queues are named after the players
   dest = "/queue/" + $("#opponentName").val();
   src = "/queue/" + $("#myName").val();
   connect();
};

// Establishing the connection
var connect = function() {
   client = Stomp.client(url);
   client.connect(un, pw, onconnect, onerror);
};

// Function invoked when connection is established
var onconnect = function() {
   console.log("connected to " + url);
   client.subscribe(src, function(message) {
      console.log("message received: " + message.body);
      // The incoming message indicates that the
      // opponent had his/her turn (picked).
      // Therefore, we draw the buttons for the opponent.
      // If this user hasn't had his/her move yet,
      // we hide the div containing the buttons, and only display
      // them when this user has had his/her move too.
      hasOpponentPicked = true;
      if (!hasUserPicked) {
         $("#opponentsButtons").css("visibility", "hidden");
         $("#instructions").html("<p>Your opponent is waiting for you. Make your move!</p>");
      } else {
         $("#instructions").html("<p>Results:</p>");
         client.disconnect( function() {
            console.log("Disconnected...");
         });
      }
        $("#opponentsButtons").html(opponentsBtns);
        switch (message.body) {
            case "rock"     :
                opponentsPick = "#opponentRockBtn";
                break;
            case "paper"    :
                opponentsPick = "#opponentPaperBtn";
               break;
            case "scissors" :
               opponentsPick = "#opponentScissorsBtn";
               break;
      }
      $(opponentsPick).css("background-color", "yellow");
   });
   console.log("subscribed to " + src);
};

var onerror = function(error) {
   console.log(error);
};

var buttonClicked = function(btn) {
   client.send(dest, null, btn.name);
   hasUserPicked = true;
   console.log("message sent: " + btn.name);

   // Setting the background color of the button
   // representing the user's choice to orange.
   // Disabling all the buttons (to prevent changing the vote).
   $("#" + btn.id).css("background-color", "orange");
   $("#rockBtn").attr("disabled", "disabled");
   $("#paperBtn").attr("disabled", "disabled");
   $("#scissorsBtn").attr("disabled", "disabled");
   // Checking if the other user has moved yet. If so,
   // we display the buttons that were drawn beforehand
   // (see onconnect)
   if (hasOpponentPicked) {
      $("#opponentsButtons").css("visibility", "visible");
      $("#instructions").html("<p>Results:</p>");
      client.disconnect(function() {
         onerror = function() {};
         console.log("Disconnected...");
      });
   } else {
      $("#instructions").html("<p>Waiting for opponent...</p>");
   }
};

要运行应用,确保 ActiveMQ 支持 WebSocket(如清单 5-4 所示),运行 ActiveMQ,然后在支持 WebSocket 的浏览器中打开index.html

监控 Apache ActiveMQ

ActiveMQ 提供了一个简单的监视界面,让您能够深入了解幕后发生的事情。要访问管理界面,请单击 ActiveMQ 欢迎页面上的管理 ActiveMQ 代理链接,或者导航到http://0.0.0.0:8161/admin/。运行一次石头剪刀布演示后,您将有两个队列,每个玩家一个。在我们的例子中,对手是 Peter 和 Vanessa,队列以他们的名字命名。如图图 5-13 所示,每个队列有一个消费者(对手玩家),我们向每个队列发送一条消息(消息入队)。这两个消息很快就出列了(消息出列)。

9781430247401_Fig05-13.jpg

图 5-13 。ActiveMQ 管理接口:监控消息队列

管理控制台还列出了当前活动的连接。在我们的演示中,我们有两个通过 ActiveMQ WebSocket 连接器创建的活动 WebSocket 连接,如图 5-14 所示。

9781430247401_Fig05-14.jpg

图 5-14 。通过 WebSocket 连接器创建的活动 ActiveMQ 连接

建议的扩展

虽然我们已经构建了一个非常简单的游戏来展示 WebSocket 上的消息传递,但是有许多方法可以扩展这个游戏,使其功能更加全面,甚至更加激动人心。当亲自玩石头剪子布时,找出赢家是游戏刺激的一部分。在网络环境中,情况就不一样了。宣布获胜者将是对该应用的一个简单但意义重大的增强。

另一个扩展是让游戏更安全。您可以通过创建一个接受移动的专用队列和另一个分发结果的队列来集中游戏逻辑,而不是依赖应用逻辑来隐藏对手的移动。这种逻辑会阻止玩家(或他们的浏览器)提前发现这些动作。此外,您可以使用主题通知所有玩家游戏结果,给获胜的玩家宣传。

要改善玩家互动,您可以:

  • 自动匹配没有对手的孤独玩家。队列是循环配对的理想选择。
  • 建造一个使用人工智能的机器人,玩家可以选择它作为对手。

网络信息的未来

将消息传递概念 与低延迟 WebSocket 通信相结合,为无数令人难以置信的应用打开了大门。正如我们在本章中看到的,实时协作的“点对点”网络和移动应用可以非常容易地构建。这些应用可以包括共享文档编辑、交互式社交演示和学习工具,以及具有实时活动流的社交软件。几乎任何类型的受众都可以利用这类应用,包括消费市场、教育、医疗和交通。

web 消息传递的另一个关键应用是机器对机器(M2M)通信领域。M2M,有时被称为“物联网”(IoT),专注于将日常物品连接到互联网。实现智能电表来跟踪并自动报告公用事业的使用情况,与家用电器互动(如检查门是否锁上或烤箱是否关闭),安装信用卡大小的计算机(如 Raspberry Pi),监控设备和移动车辆,遥测和增强现实只是 M2M 解决的几个用例。

大多数现代实时企业采用企业服务架构作为高效 IT 基础设施的一部分。面向不同客户的应用或内部应用向通用服务发出请求,以高效地交付收入和生产力。十多年来,企业服务总线(ESB ) 的概念已经成为全球公司接受的模型。WebSocket 现在允许将这些企业服务安全地扩展到任何 web 设备,从而允许与客户、合作伙伴和移动员工建立更加协作的关系。

通过在用户的浏览器会话、移动设备或胖桌面客户端应用期间消费、分析和主动响应事件,复杂事件流程引擎也可以从 WebSocket 架构中受益。

以类似的方式,BPMS (业务流程管理系统)将能够更新任务的状态,这些任务是在整个企业中执行的大型业务流程的一部分,并向用户实时显示业务的相关部分正在发生什么。

很明显,网络正在从一个文档的世界变成一个活动的世界,在这个世界里,活跃的是应用,而不是文档。WebSocket 是这个新网络的关键组成部分,它将极大地改变我们在企业甚至日常生活中使用网络的方式。WebSocket 的性质提供了与内部公司客户已经用来连接到企业服务总线的连接相同的类型,但是将它们扩展到了 Web。

一组新的和不断发展的功能组合在 Web RTC(实时协作)保护伞下,具有基于浏览器的视频和音频馈送,正在带我们超越实时数据,踏上更加令人兴奋的旅程。

摘要

在本章中,我们回顾了消息传递的概念,这是一种以发送异步消息来构建松耦合系统为特征的架构风格。您了解了 pub/sub 模式,以及 STOMP,一种开放的消息传递协议。我们探索了一个开源的 STOMP 和支持 WebSocket 的消息代理 Apache ActiveMQ。在了解了简单的配置更改后,我们运行了 ActiveMQ STOMP-WebSocket 演示,然后我们自己构建了一个:石头剪子布。最后,我们回顾了 Apache ActiveMQ 的监控和管理功能。

在下一章中,我们将在 WebSocket 的基础上使用 RFB,VNC 使用的协议,建立一个纯粹使用 HTML5 的实时桌面共享体验。

六、VNC 与远程帧缓冲协议

在前面的章节中,您学习了如何在 WebSocket 上分层两个强大的协议,XMPP 和 STOMP。有了这些协议,我们能够检查聊天、在线状态和消息传递,所有这些都可以用来创建丰富的应用和实现系统,为我们基于浏览器的世界提供动力。在第四章中,我们看到了如何将广泛使用的标准聊天协议与 WebSocket 结合使用,并支持在 Web 上使用传统的基于桌面的聊天应用,同时见证了将 WebSocket 与标准聊天协议分层的好处。同样,在第五章中,我们看了如何从 web 应用中与基于 TCP 的消息代理交互。在这两种情况下,我们探索了从传统的基于桌面应用的世界到支持 web 的世界的过渡,并研究了 WebSocket 提供的全双工、低延迟的 Web 连接如何有利于此类应用。在这一章中,我们来看一个更复杂的(标准的)协议,以及如何使用 WebSocket 作为通信平台来转换它。

随着应用分布在使用各种操作系统、程序和浏览器版本的台式机上,用户不依赖于系统、信息技术团队能够从任何地方支持任何系统以及应用开发人员能够在任何系统上操作变得越来越重要。有时,用户需要访问特定的操作系统。访问特定系统的一种流行方式是使用 VNC(虚拟网络计算)。

VNC 让你在任何网络上共享桌面。它本质上允许你远程查看和控制另一台计算机的界面,并且可以被认为是等同于 Telnet 的 GUI(图形用户界面)。你也可以把 VNC 想象成一根长长的虚拟电缆,它可以让你通过鼠标、键盘和视频信号来查看和控制另一个桌面。

顾名思义,VNC 用于网络。由于我们将在本章中探讨的挑战,VNC 还不容易在 Web 上使用。借助 HTML5 和 WebSocket,我们可以克服其中一些挑战,并研究高度可移植的富互联网应用如何利用 HTML5 和 WebSocket 来使用 VNC。

在本章中,我们将探索如何使用 WebSocket 和远程帧缓冲(RFB)协议来将虚拟网络计算扩展到 Web。我们还将看看,作为一个二进制协议,RFB 如何以不同于我们在前两章讨论的面向文本的协议的方式使用 WebSocket API。在看了 RFB 和 VNC 之后,我们将逐步介绍如何通过 WebSocket 使用 RFB 构建一个连接到开源 VNC 服务器的 VNC 客户端。我们将介绍通过 WebSocket 实现屏幕共享(VNC 的典型用例)的技术,并研究如何通过键盘和鼠标实现远程设备输入。声音复杂?RFB 确实是一个比 XMPP 和 STOMP 更复杂的协议。

本书附带的代码示例包含完整的端到端 RFB over WebSocket 应用,您可以在 VNC 服务器上运行该应用。但是,如果您不希望解决 RFB 的复杂性,您可以参考我们提供的虚拟机(VM)来遵循本章中的步骤(有关说明,请参见附录 B )。VM 包含您可以随意运行、检查和消化的工作代码。在本章的动手操作部分,我们将重点介绍应用中与 WebSocket 和在 WebSocket 客户端上构建 RFB 的技术特别相关的代码片段。在您从高层次上探索了本章中的思想之后,您可以自己运行代码,看看它们是如何一起工作的。然后,为了更仔细地分析代码,您可以在 VM 中打开代码示例。

RFB 在 WebSocket 上的分层可能不适合胆小的人,但这个引人注目的例子与一些更常见的 WebSocket 用例(如聊天)形成了对比,因为它说明了使用 HTML5 和 WebSocket 可以实现更多的交互式和图形化功能。此外,它还展示了 WebSocket 如何帮助连接 HTML5 和遗留系统。

image 注意我们在本章中使用的 WebSocket 上的 VNC 演示最初是由 Kaazing 在 2010 年开发的,用于展示 WebSocket 技术。

虚拟网络计算概述

几十年来,桌面计算一直非常流行。从历史上看,流行的桌面操作系统都有联网的窗口系统和远程访问协议,使用户能够从终端和其它 PC 上使用它们的系统。在过去的几十年里,个人电脑的兴起也刺激了桌面应用的爆炸。这些桌面应用中的大部分现在都是遗留应用,并且不是所有这些遗留应用都有可比较的替代方案。VNC 是一种标准方式,使用户和系统能够继续访问传统应用和系统,而无需考虑操作系统的兼容性。VNC 还使您能够与另一台计算机上的系统和应用进行远程交互,就好像您实际上正在使用那台计算机一样。

图 6-1 显示了一个桌面通过网络控制另一台计算机的鼠标和键盘。远程显示器的像素在控制机器上被复制。

9781430247401_Fig06-01.jpg

图 6-1 。通过互联网访问另一台电脑的桌面

从软件测试和部署到教育,VNC 在很多方面都非常有用。在软件开发环境中,您可以在操作系统、应用和应用版本的各种组合中测试您的应用,而无需离开您自己的桌面。例如,您可以在任何类型的系统上的任何浏览器(传统或非传统)中测试新的 STOMP 或 XMPP over WebSocket 应用,例如 Mac OS 上的 Google Chrome。当您需要访问不可用或无法亲自安装的遗留应用时,这种能力非常有用。

VNC 对于协作或教育也非常有用,不仅需要屏幕共享,还需要访问他人的桌面来帮助使用特定的应用。例如,想象一下,一名建筑系学生使用 CAD(计算机辅助设计)应用来设计一个房间。助教可能能够更好地向学生解释在哪里调整尺寸,而不需要在他或她自己的计算机上安装 CAD 应用,也不需要亲自与学生见面。类似地,技术人员可以诊断和修复用户的计算机,而不需要在现场。

远程访问桌面有几种协议。其中一些是特定于平台的,如微软的远程桌面协议(RDP) ,X Window 系统或 X11 (用于 UNIX、Linux 和 Mac OS X),Chromoting(用于谷歌 Chromebook),苹果远程桌面(ARD) ,以及 NX(用于 Linux 和 Solaris)。其他的,像远程帧缓冲区(RFB),是跨平台的。

VNC 是一种基于 RFB 协议的开源技术,因此是独立于平台的。RFB 是一个 IETF 规范,是许多 VNC 服务器的基础,也是一个蓬勃发展的社区,可以在您需要时提供优化。因为它被广泛使用,所以有许多资源可以帮助你开始使用,并帮助你让 VNC 在你的网络中工作。

虽然 VNC 在您的网络中相当普遍且易于实现,但 VNC 协议通常在 web 应用中工作得不太好。已经有了用于远程桌面访问的 AJAX 应用,但是它们并不是特别理想,因为 HTTP 的请求-响应通信对于传输这些协议来说并不理想。远程桌面应用本质上是双向的。用户可以随时执行输入操作。同样,显示可以随时更新。双向传输层协议对于创建高效的远程桌面应用至关重要。有基于插件的远程桌面应用在浏览器中运行,但通过 WebSocket,我们可以将这些应用带到纯 HTML5 环境中。

为了更好地理解 VNC 的底层技术如何与 WebSocket 一起工作,让我们仔细看看 RFB,以及面向二进制和面向文本的协议之间的区别。

远程帧缓冲协议概述

远程帧缓冲区(RFB)协议是来自 IETF (RFC 6143)的信息规范。虽然它不是官方标准,但它被广泛使用,并且有许多可互操作的实现。RFC 6143 本身已经有十多年的历史了,并且已经修改过几次。

让我们来分解一下协议定义。帧缓冲区是包含图形计算机系统显示的所有像素值的数组,是台式计算机的最小公分母模型。因此,RFB 是远程访问帧缓冲区的一种方式。对于任何有键盘、鼠标和屏幕的系统,可能都有一种通过 RFB 访问它们的方法。

RFB 协议被设计成让服务器去做“繁重的工作”,使客户机变得简单和瘦。根据 RFB 协议构建的客户端也是无状态的,这意味着如果客户端断开连接并重新连接,新会话不会丢失帧缓冲区的状态。

面向二进制和文本的协议

协议通常面向二进制数据或文本字符串。二进制协议可以比面向文本的协议更紧凑,并且可以灵活自然地嵌入图像、音频和视频等任意二进制数据结构。二进制协议旨在由机器而不是人类来阅读,并且可以优化以任何形式发送的数据结构以保持效率。

面向文本的协议,如 STOMP 和 XMPP,倾向于在网络上传输相对较大的消息,因此,与二进制协议相比,解析起来更昂贵。然而,面向文本的协议实际上可以由任何语言实现,是人类可读的,并且具有灵活的可变长度字段。虽然二进制协议可能是传输数据的更有效的方式,但是面向文本的协议可能给你更多的灵活性,并且更容易实现和部署。

RFB 是一种二进制协议,传输二进制图像数据。数据可以被压缩,并且可以以非常高的更新频率流入和流出服务器。图像数据可以以高频率从服务器流出;类似地,客户端可以生成由用户移动鼠标和按键引起的输入事件流。这些输入事件以二进制格式压缩编码,传输时只需很少的字节。WebSocket 协议可以处理二进制数据或文本字符串。因此,二进制 WebSocket 消息非常适合 RFB 协议。

image 注意 Wireshark 支持分析 RFB 协议会话,这在调试新实现时会很有用。有关更多信息,请参见第三章,其中我们讨论了检查 WebSocket 协议,以及附录 A ,其中我们讨论了使用 Wireshark 剖析和调试 WebSocket 流量。

选择通过 WebSocket 使用 RFB

正如我们在第四章中讨论的,你可以建立自己的聊天协议;类似地,您可以构建自己的远程访问协议,该协议只适用于您的应用。但是,正如我们也提到的,您将错过使用广泛使用的、开放的、可互操作的协议的巨大好处。例如,有许多为 VNC 设计的基于 RFB 的跨平台服务器,其中许多都被不断增长的开发人员社区不断优化和增强。随着新操作系统的开发和版本化,您可以与社区合作来利用这些好处,并专注于您希望您的应用做什么。

在下一节中,我们将介绍一个使用 VNC 的基本示例,该示例说明了如何通过 WebSocket 使用 RFB:使用 web 客户端查看另一台计算机的屏幕并对其进行控制(使用键盘和鼠标)。图 6-2 说明了我们例子中的信息流。在这里,一个 RFB 客户端在浏览器选项卡中运行,并使用 web 客户端和 RFB 服务器之间的 WebSocket to TCP 代理与 RFB (VNC)服务器通信。使用这个客户端,用户可以通过 WebSocket 和 RFB 完全在 web 应用中查看和控制远程桌面。

9781430247401_Fig06-02.jpg

图 6-2 。通过 WebSocket 与 RFB 连接

通过 WebSocket 构建 VNC (RFB)客户端

既然我们已经研究了基于 WebSocket 的 VNC 背后的一些概念,让我们来看一个工作示例。

image 注意在本节中,“VNC”指的是使用 RFB 作为底层协议的远程桌面连接,我们将使用 RFB 作为 VNC 的组件称为“RFB 组件”(特别是“RFB 客户端”和“RFB 服务器”)。通过使用 RFB,您可以有效地构建一个 VNC 应用。

在这个例子中,我们来看看如何将这种流行且广泛使用的技术与 WebSocket 结合起来。我们研究了使用 HTML5 和 WebSocket 构建的 RFB 客户端的关键组件,该客户端可以查看和控制另一台计算机的图形用户界面。

我们已经看到 WebSocket 如何将 HTML5 应用提升为一流的网络参与者。本节中的客户端应用在各方面都很像桌面 RFB 客户端,只是它是使用 web 技术实现的,并且在浏览器中运行。我们还添加了远程设备输入,这允许您使用键盘和鼠标控制其他 GUI。本节中的步骤不仅向您展示了如何通过 RFB 和 WebSocket 控制远程计算机的 GUI,当您构建自己的使用 WebSocket 的图形应用时,您可能会发现这个示例很有启发性。最后,我们将研究一些令人兴奋的应用,您可以构建这些应用来让您的用户远程控制另一台计算机——所有这些都可以在浏览器选项卡中完成。

这个应用的客户端被分成两层。协议库,RfbClient.js,包括 JavaScript 中 RFB 协议的实现。这个库处理由所有兼容服务器理解的规范定义的 RFB 语法。

客户端的用户界面由vnc.htmlui.jsvnc.css组成。这些文件分别定义了 VNC 应用的页面结构、应用行为和外观。在服务器端,我们使用 Node.js 脚本将 WebSocket 连接代理到 TCP 连接。该代理连接到运行在远程桌面上的后端 RFB 服务器。

设置代理服务器

RFB 是一种应用层协议,它将 TCP 用于传输层。这种分层现在应该相当熟悉了,因为它是前两章中的协议所共有的主题。正如在第四章中所讨论的,当在 WebSocket 上使用标准 TCP 协议时,您可以选择升级服务器以接受 WebSocket 连接,或者使用代理在 WebSocket 和 TCP 之间进行中继。

在清单 6-1 所示的例子中,我们使用了一个简单的代理,它改编自我们在第三章中编写的 Node.js 服务器。这个代理不知道 RFB,并且完全与应用无关。它只是处理传入的 WebSocket 连接,并建立传出的 TCP 连接。数据流在代理的一端作为 WebSocket 消息流入,在另一端作为 TCP 流出。在我们完成的应用中,代理将处理来自 RFB 客户端的连接,并通过 TCP 代理 WebSocket 连接到后端 RFB 服务器。参考上一节的图 6-2 ,它显示了代理服务器在我们架构中的位置。

清单 6-1。 代理服务器代码

var websocket = require("./websocket-example");
var net = require("net");

var remotePort = 5900;
var remoteHost = "192.168.56.101";

websocket.listen(8080, "localhost", function(websocket) {
    // set up backend TCP connection
    var tcpsocket = new net.Socket({type:"tcp4"});
    tcpsocket.connect(remotePort, remoteHost);

    // TCP handler functions
    tcpsocket.on("connect", function() {
      console.log("TCP connection open");
    });
    tcpsocket.on("data", function(data) {
      websocket.send(data);
    });
    tcpsocket.on("error", function() {
      console.log("TCP connection error", arguments);
    });

    // WebSocket handler functions
    websocket.on("data", function(opcode, data) {
        tcpsocket.write(data);
    });
    websocket.on("close", function(code, reason) {
        console.log("WebSocket closed")
        // close backend connection
        tcpsocket.end();
    });

    console.log("WebSocket connection open");
});

虽然有许多 RFB 服务器直接接受 WebSocket 连接,但是在这个例子中,您可以灵活地使用任何兼容的 RFB 服务器。但是,请注意,因为 RFB 到 WebSocket 的绑定还没有被指定,所以可能会有一些潜在的复杂性和不兼容性。在我们的例子中,我们在一个虚拟机上使用流行且广泛使用的开源 TightVNC 服务器(基于 RFB)。TightVNC 目前还不支持 WebSocket,但是可以与我们的代理服务器一起工作。

虚拟机的地址被硬编码到代理脚本中。硬编码地址方便开发但是适合生产。要在您的环境中使用它,您可能需要更改主机名和端口变量。此外,我们应该强调的是,这个例子中的 WebSocket 服务器和 VNC 服务器都没有对传入的连接进行身份验证,这对于除了简单的演示之外的任何目的来说都是非常不明智的。更好的安全措施见第七章。

RFB 客户端

既然我们已经设置了一个可以通过 WebSocket 连接接受 RFB 的代理服务器,我们就可以构建前端部分,或者说客户端了。虽然 RFB 客户端本质上是瘦的,但我们希望它能够查看 RFB 服务器的屏幕,这包括接收关于屏幕上正在发生的事情的图形信息。我们还希望能够控制远程计算机(也称为 RFB 服务器)。

在本节中,我们将探索:

  • 用 JavaScript 构建简单的客户端

  • 处理来自 RFB 协议和 WebSocket 协议的二进制数据的技术

  • 连接到服务器

  • 使客户端能够接受帧缓冲区更新

  • 使用 HTML5

    呈现帧缓冲区

  • 处理设备输入

在 JavaScript 中实现 RFB

这个 RFB 应用的客户端是一个运行在浏览器中的 HTML5 应用。它利用了 HTML、CSS 和 JavaScript。基本的用户界面是由一些 HTML 标记定义的。应用的逻辑,包括使用 RFB 协议进行通信的库,是用 JavaScript 编写的。

清单 6-2 显示了包含协议库和应用脚本的起始 HTML :

清单 6-2。 用协议库和应用脚本启动 HTML】

<!DOCTYPE html>
<title>RFB over WebSocket</title>

<script src="bytes.js"></script>
<script src="RfbClient.js"></script>
<script src="ui.js"></script>

image ** Pro 提示**因为 JavaScript 完全是事件驱动的,所以没有办法在运行的函数内部等待更多的字节变得可用。每个函数都必须运行到完成并快速返回。为了使 JavaScript 应用能够接收 RFB 协议消息,我们将该协议映射到可以在浏览器中运行的事件处理程序。这种设计技术对于实现许多不同类型的协议非常有用。

使用字节流

在第二章中,我们演示了如何使用 WebSocket API 发送和接收二进制数据。编写二进制消息就像用 Blob 和 ArrayBuffer 参数调用WebSocket.send() 一样简单。读取二进制消息是自动的,因为传入消息事件的类型与WebSocket.binaryType匹配。通过 WebSocket API 使用 WebSocket 消息进行通信,并通过与 WebSocket 协议的消息对齐绑定来实现协议是很简单的。相比之下,像 RFB 这样的任意应用级协议都不符合 WebSocket 框架。这种协议的语法是根据字节流定义的。每个对WebSocket.onmessage的调用不能保证包含一个且只有一个完整的 RFB 消息。RFB 消息可以被分割或合并成比预期数量更多或更少的 WebSocket 消息。流抽象有助于弥合两种通信模式之间的差距。在清单 6-3 中,文件bytes.js包含了RfbClient.js用来简化字节流读写的实用程序。特别是,它包含一个 CompositeStream API,将离散的 ArrayBuffers 序列连接到逻辑字节流中。当二进制 WebSocket 消息到达时,RfbClient.js调用CompositeStream.append() 将新字节添加到入站流中。为了在 RFB 协议层读取和解析消息,RFB 处理程序代码调用CompositeStream.consume() 从流中提取字节。类似地,RfbClient.js使用bytes.js中的函数将数字以字节形式写入给服务器的消息中。这些函数利用标准的DataView类型,调用 ArrayBuffers 上的 setter 方法,用字节表示 8、16 和 32 位整数。清单 6-3 显示了bytes.js中的数值函数。

清单 6-3。bytes . js 中的数值函数

$prototype.appendBytes = function appendBytes() {
    ba = new Uint8Array(arguments);
    this.append(ba.buffer);
}

$prototype.appendUint16 = function appendUint16(n) {
    var b = new ArrayBuffer(2);
    var dv = new DataView(b);
    dv.setUint16(0, n);
    this.append(b);
}

$prototype.appendUint32 = function appendUint32(n) {
    var b = new ArrayBuffer(4);
    var dv = new DataView(b);
    dv.setUint32(0, n);
    this.append(b);
}

这些函数使得用 RFB 协议的语法编写包含消息的字节数组变得更加容易。

设置连接

RfbProtocolClient connect函数设置初始客户端状态,并为该套接字创建一个空流、一个 WebSocket 和事件处理程序。该函数还将第一个readHandler设置为versionHandler,因为 RFB 协议从服务器和客户端之间的版本信息交换开始。清单 6-4 显示了RfbProtocolClient连接函数,我们必须设置它来连接我们的服务器。connect 函数还构造了一个空的复合流。该流将包含代表来自服务器的部分 RFB 消息的字节。

清单 6-4。 RfbProtocolClient 连接函数

RfbProtocolClient = function() {};

$prototype = RfbProtocolClient.prototype;

$prototype.connect = function(url) {
    this.socket = new WebSocket(url);
    this.socket.binaryType = "arraybuffer";
    this.stream = new CompositeStream();

    bindSocketHandlers(this, this.socket);

    this.buttonMask = 0;
    // set first handler
    this.readHandler = versionHandler;
}

bindSocketHandlers() 函数设置该协议客户端使用的 WebSocket 事件处理程序。消息处理程序做了一些有趣的事情:它将任何传入的数据添加到字节流中,并调用当前的读取处理程序,然后继续调用当前的读取处理程序,直到该处理程序返回false。这个函数允许消息处理程序有效地遍历传入的数据,并处理任意数量的消息。如果流中还有部分消息,它将一直保留在那里,直到套接字产生另一个消息事件。这时,最后一个返回false的读取处理程序被再次调用。额外字节的存在可能会导致处理程序返回true。清单 6-5 显示了bindSocketHandlers()功能。

清单 6-5。bindSocketHandlers()函数

var bindSocketHandlers = function($this, socket) {
    socket.onopen = function(e) {
        // Ignore WebSocket open event.
        // The server will send the first message.
    }

    var stream = $this.stream;
    socket.onmessage = function messageHandler(e) {
        // Append bytes to stream.
        stream.append(e.data);
        // Read handler loop.
        while($this.readHandler($this, stream)) {
            // Do nothing.
        }
    }

    socket.onclose = socket.onerror = function() {
        console.log("Connection closed", arguments);
    }
}

每个事件处理程序都期望一定数量的字节能够读取完整的消息。如果传入流中的字节较少,处理程序返回false,这将 WebSocket 消息事件处理程序重新置于等待状态。如果有足够的字节来读取一个完整的协议数据消息,处理程序将从流中读取足够多的字节,对它们进行处理,并返回 true。每个处理程序还可以在返回true之前设置下一个处理程序变量。

在清单 6-6 的中,versionHandler()函数将readHandler变量设置为numSecurityTypesHandler,因为客户端进入的下一个状态将读取包含服务器支持的安全类型数量的消息。

清单 6-6。version handler()函数

var versionHandler = function($this, stream) {
    if (stream.length < 12) {
        return false;
    }

    var version = new Uint8Array(stream.consume(12));
    // Echo back version.
    sendBytes($this, version.buffer)

    // Set next handler.
    $this.readHandler = numSecurityTypesHandler;
    return true;
}

使客户端能够接受帧缓冲区更新

一旦您将客户端连接到服务器,客户端必须发送请求帧缓冲区更新的消息。这样做将使客户端能够从 RFB 服务器接收数据。

RFB 协议定义了不同类型的帧缓冲区更新。每种类型都在协议消息中用数字代码表示。在这个例子中,我们将只使用两种基本的编码类型:Raw 和 CopyRect。Raw,正如您可能猜到的,将像素数据表示为原始的、未压缩的位图。CopyRect 是来自服务器的指令,用于将当前位图的一部分复制到屏幕上的其他位置。由于许多用户界面包含大的纯色区域,这可能是更新客户端屏幕的非常有效的方式。

当流中不再有矩形时,客户端可以请求另一次更新。如清单 6-7 中的所示,这种实现是轮询和流式传输数据之间的一种折衷,目的是抑制服务器发送的更新,而又不至于过于冗长。

清单 6-7。 帧缓冲请求

var doUpdateRequest = function doUpdateRequest($this, incremental) {
    var request = new CompositeStream();

    request.appendBytes(3);             // type (u8 3)
    request.appendBytes(1);             // incremental

    request.appendBytes(0,0,0,0);       // top left corner: x (u16 0) y (u16 0)
    request.appendUint16($this.width);  // width (u16 800)
    request.appendUint16($this.height); // height (u16 600)

    sendBytes($this, request.consume(request.length));
}

清单 6-7 显示了 framebuffer 请求,它指定了要更新的区域左上角的位置。它们还包含区域的高度和宽度,这允许客户端选择只更新帧缓冲区的一部分。对于这个例子,我们将总是在整个画布上请求更新。增量字节向服务器指示客户端具有当前帧缓冲区的副本,并且可以应用更新。这比一遍又一遍地发送整个屏幕的内容更有效。

使用 HTML5

绘制一个帧缓冲区

既然客户端可以接受 framebuffer 更新,那么让我们在客户端上呈现这些信息,这使得客户端能够查看来自 RFB 服务器(或者在我们的例子中是 TightVNC )的 GUI 信息。

HTML5 中最重要的新元素之一是<canvas><canvas>元素支持 2d 绘图 API,该 API 赋予 HTML5 应用操纵像素图形的能力。为了在应用中有效地显示帧缓冲区,需要低级绘图,因为应用代码必须能够单独设置每个像素的颜色。

清单 6-8 创建一个canvas元素,设置它的初始宽度和高度,并获得一个绘图上下文。

清单 6-8。 创建画布元素

Screen = function(width, height) {
    this.canvas = document.createElement("canvas");
    this.canvas.setAttribute("height", height);
    this.canvas.setAttribute("width", width);
    this.context = this.canvas.getContext("2d");
}

2d 绘图上下文提供了用于与 canvas 元素交互的绘图 API。许多 2d 上下文函数处理绘制原始形状。像fillRect这样的函数非常适合显示大多数编程生成的图形。为了显示一个帧缓冲区,我们需要使用 canvas 2d context 公开的底层函数。putImageData()函数是一个理想的低级像素函数,直接从颜色数据数组中设置画布的像素值。清单 6-9 显示了这个函数的一个例子。

清单 6-9。putImageData()函数

context.putImageData(imageData, xPos, yPos);

类似地,有一个getImageData()函数可以从画布上下文中检索像素值,如清单 6-10 中的所示。

清单 6-10。 一个 getImageData()函数

context.getImageData(xSrc, ySrc, width, height);

我们将使用这些画布函数用来自 RFB 协议的帧缓冲区更新来更新画布。方便起见,ImageData是一种兼容 WebSocket 发送和接收的二进制消息的类型。事实上,在现代浏览器中,getImageData()返回一个Uint8ClampedArray,可以包装数组缓冲区的 TypedArray 视图之一。

image 注意支持 canvas 的老浏览器用的是一个过时的ImageData类型,不是类型化数组;必须转换成一个。

为了呈现客户端帧缓冲区,RfbClient.js处理两种更新:Raw 和CopyRect。更高级的客户端也可以处理其他像素编码。

原始像素数据

最简单的帧缓冲区更新由原始像素数据组成。在 RFB 协议中,原始像素用编码类型零(0x0)表示。清单 6-11 显示了像素数据,它仅仅由帧缓冲区更新部分中每个像素的红、绿、蓝值组成。

清单 6-11。 原始像素数据

$prototype.putPixels = function putPixels(array, width, height, xPos, yPos) {
    var imageData = this.context.createImageData(width, height);
    copyAndTransformImageData(array, imageData);
    this.context.putImageData(imageData, xPos, yPos);
}

copy rect 的缩写形式

本例中使用的第二种编码类型是copyRect,它是 RFB 协议中的编码类型一(0x01)。这个函数是一个巧妙的操作,非常适合传送主要由相同的重复像素值组成的帧缓冲区更新。

就像原始编码一样,copyRect矩形消息指定位置、宽度和高度。该信息表示更新将填充的帧缓冲区中的目标矩形。然而,copyRect消息并没有发送该矩形的当前像素数据,而是多了两个有效载荷值:源矩形的 X 和 Y 位置。源矩形与目的矩形或目标矩形具有相同的宽度和高度。源中的每个像素都被一字不差地复制到目标区域的相应像素中。

要实现copyRect,我们需要getImageDataputImageDatacopyRect()函数包含源像素的位置、宽度和高度以及目标像素的位置。它在 framebuffer 画布上的操作与 raw putPixels函数非常相似,只是它从当前画布上获取像素数据。清单 6-12 显示了带有getImageDataputImageDatacopyRect()功能

.

清单 6-12。copyRect()函数

$prototype.copyRect = function copyRect(width, height, xPos, yPos, xSrc, ySrc){
 // get pixel data from the current framebuffer
 var imageData = this.context.getImageData(xSrc, ySrc, width, height);
 // put pixel data in target region
 this.context.putImageData(imageData, xPos, yPos);

其他编码和效率

虽然通过互联网发送原始像素是非常低效的,但是当你足够幸运地在你的帧缓冲区中有重复的像素值时,copyRect是非常高效的。真实的桌面比这更复杂,因此有更多的高级编码可用于减少 RFB 的带宽使用。这些编码使用压缩算法来减少发送像素数据所需的带宽。例如,编码 16 (0x10)使用 ZLIB 进行压缩。这是您在真正的基于 RFB 的应用中最想使用的编码风格。大多数 RFB 客户端和服务器支持压缩更新。

在客户端处理输入

到目前为止,我们已经构建了足够的 RFB 客户端,能够实时观察桌面更新(实际上是屏幕共享)。为了与桌面交互,我们需要处理用户输入,这将允许 RFB 客户端控制 RFB 服务器的鼠标和键盘。在本节中,我们将研究如何在客户端接受鼠标和键盘输入,并将输入信息传递给 RFB 服务器。

客户端到服务器的消息

RFB 协议定义了客户端发送给服务器的消息类型。这些消息类型表示客户端发送给服务器的消息类型。如前所述,消息类型是客户端到服务器消息的第一个字节,用整数表示。表 6-1 描述了 RFB 规范中存在的消息类型。

表 6-1。RFB 规范中的消息类型

消息类型号 消息类型
Zero SetPixelFormat
Two SetEncodings
three FramebufferUpdateRequest
four KeyEvent
five PointerEvent
six ClientCutText

在这一节中,我们将重点关注 RFB 客户端到服务器的消息类型 4 和 5,分别是键盘和鼠标事件。

鼠标输入

处理鼠标和键盘输入是我们的示例实现的重要部分,它代表了从键盘和鼠标点击中捕获动作。这些动作触发从客户机发送到服务器的 JavaScript 事件。这使得 VNC 用户可以控制远程系统上运行的应用。在我们的应用中,我们将检测 JavaScript 中的输入事件,并通过我们的 open WebSocket 发送相应的 RFB 协议消息。

在 RFB 协议中,PointerEvent代表移动或点击设备按钮或释放。PointerEvent消息是一个二进制事件消息,包括一个消息类型字节,指定哪种消息被发送到服务器(例如,定点设备点击、定点设备移动等等),一个按钮掩码字节,携带由比特 0 到 7 表示的从 1 到 8 的定点设备按钮的当前状态,其中 0 表示按钮被抬起,1 表示按钮被按下或按下, 以及两个位置值,每个位置值由一个无符号短整数组成,代表相对于屏幕的 X 和 Y 坐标(见图 6-3 )。

9781430247401_Fig06-03.jpg

图 6-3 。按下鼠标左键会产生一个 6 字节的二进制指针事件消息

清单 6-13 显示了鼠标事件。

清单 6-13。 鼠标事件

var doMouseEvent = function ($this, e) {
    var event = new CompositeStream();

    event.appendBytes(5);     // type (u8 5)
    event.appendBytes($this.buttonMask);

    // position
    event.appendUint16(e.offsetX);
    event.appendUint16(e.offsetY);

    sendBytes($this, event.consume(event.length));
}

清单 6-14 表明当检测到鼠标移动时,鼠标事件被发送到 VNC 服务器。

清单 6-14。 鼠标事件传到 VNC 服务器

$prototype.mouseMoveHandler = function($this, e) {
 doMouseEvent($this, e);
}

类似地,当检测到鼠标点击时,被点击的按钮作为鼠标事件被传输,如清单 6-15 中的所示。

清单 6-15。 发送鼠标点击作为鼠标事件

$prototype.mouseDownHandler = function($this, e) {
    if (e.which == 1) {
        // left click
        $this.buttonMask ^= 1;
    } else if (e.which == 3) {
        // right click
        $this.buttonMask ^= (1<<2);
    }
    doMouseEvent($this, e);
}

表 6-2 描述了与 RFB 事件监听器相关的鼠标事件类型。

表 6-2。鼠标事件类型

事件类型 描述
mousedown 指示定点设备在元素上被按下
mouseup 指示在元素上释放了定点设备
mouseover 指示定点设备位于元素上方
mousemove 指示定点设备在元素上时被移动

键盘输入

除了PointerEvent消息之外,RFB 协议规定KeyEvent消息指示按键被按下或释放。KeyEvent消息是一个二进制事件消息,包括一个消息类型字节,指定发送给服务器的消息类型(在这种情况下是一个键盘事件),一个 down-flag 字节,指示当值为 1 时按键是否被按下,或者如果值为 0 时按键是否被释放,两个字节的填充,以及在 key 字段的四个字节中指定的按键本身。图 6-4 显示了键盘输入和KeyEvent之间的关系。

9781430247401_Fig06-04.jpg

图 6-4 。按下“T”键会生成一个 8 字节的二进制按键事件消息

RFB 协议使用与 X Window 系统相同的键码,即使客户机(或服务器)没有运行 X Window 系统。这些代码与 DOM KeyboardEvents 上的键码不同,因此映射函数是必要的。清单 6-16 展示了我们例子中的KeyEvent函数。

清单 6-16。 按键事件()功能

var doKeyEvent = function doKeyEvent($this, key, downFlag) {
    var event = new CompositeStream();

    event.appendBytes(4);     // type (u8 4)
    event.appendBytes(downFlag);
    event.appendBytes(0,0);   // padding

    event.appendUint32(key);

    sendBytes($this, event.consume(event.length));
}

在清单 6-16 中, doKeyEvent() 函数接受一个键值和一个downFlag并构造相应的 RFB 客户端消息。填充字节似乎很浪费。今天显然是这样,因为网络带宽和延迟比 CPU 周期更宝贵。将整数值与 32 位边界对齐是协议设计中残留的优化,在一些计算平台上,这种优化用网络带宽的字节来换取处理速度。因为我们是在 JavaScript 中生成这些值的,而 JavaScript 甚至没有 32 位整数类型,所以看起来非常有趣!

image 注意键盘事件依赖于设备,这意味着要生成键盘事件,必须与操作系统进行映射。

表 6-3 描述了 DOM 键盘事件的类型。

表 6-3。 DOM 键盘事件类型

事件类型 描述
keydown 表示在键盘上按下了特定的键,并在keypress事件之前触发
keyup 指示释放了特定的键

这个例子使用了event.which属性来检测键盘按键。该属性是一个传统的 DOM API,但适用于本示例的目的。属性返回被按下或释放的键所代表的字符的值。我们可以将该值映射到 RFB 在KeyEvent消息中使用的键值。

把这一切放在一起

此时,我们已经准备好连接到 RFB 服务器,并开始使用远程桌面。在支持所需 JavaScript APIs 的现代浏览器中打开vnc.html。这可能会在您的本地文件系统中工作。否则,从一个可以用浏览器访问的位置提供这个示例的所有静态文件。

启动 WebSocket-to-TCP 代理和后端 RFB 服务器。当您在应用中按下 Connect 按钮时,您应该会在浏览器选项卡中看到一个远程桌面。尝试使用远程系统的用户界面。太神奇了。

image 注意请记住,您可以简单地从虚拟机安装和启动服务器和客户端。更多信息参见附录 B 。

增强应用

为了增强这个应用,您有几个显而易见的选择。首先,您可以尝试将 WebSocket 移除到 TCP 代理,并直接连接到包含集成 WebSocket 支持的 RFB 服务器。您还可以实现其他 RFB 功能;RFB 支持本例中未包括的许多其他像素编码机制。还有一些可协商的认证机制,您可以添加对它们的支持。此外,RFB 服务器可以配置为支持不同的颜色深度。您可以将这些模式添加到 RFB 客户端协议库中。

借助 VNC/RFB 在 WebSocket 技术上的力量,你应该能够设计一个 web 应用,连接到同一个网页上的多个桌面,作为与它们一起工作的一种方式。想象一下,你在一个网页上有三个面板,每个面板都通过 WebSocket 上的 VNC 连接到一个远程系统,你可以在一个控制 Windows 系统的面板上执行一个操作,切换到另一个控制 Linux 系统的面板,然后最后关注控制 Mac OS 的第三个面板。当这三个远程系统并行运行时,您可以在浏览器或桌面上执行其他任务。

你在本章中学到的机制仅仅是你可以用 WebSocket 开发的一个开端,即从一个网页同时远程连接到几台不同的机器,而不需要在你的系统上安装任何东西。

摘要

在本章中,我们讨论了网络计算历史中的一些要点,特别是虚拟网络计算。我们研究了广泛实现的远程帧缓冲区(RFB)协议,逐步介绍了如何对 RFB 和 WebSocket 进行分层,使您能够远程控制另一台计算机的 GUI,并重点介绍了通过 WebSocket 使用 RFB 的关键技术。我们还研究了 VNC 相对于 WebSocket 的一些教育和技术优势,以及面向二进制和面向文本协议的比较。我们还探索了潜在的令人兴奋的用途和应用,您可以构建这些用途和应用,使用户能够执行他们以前无法执行的任务,例如从同一个网页查看和控制多个桌面。

现在我们已经完成了一些 WebSocket 的真实演示和用例,我们将在下一章讨论 WebSocket 安全性以及如何保护 WebSocket 应用。

七、WebSocket 安全性

到目前为止,本书中的章节已经向您展示了 WebSocket 是如何在 Web 上实现全双工、双向通信的。我们已经了解了如何将 WebSocket 与常用的标准协议如 XMPP 和 STOMP 进行分层,使您能够将基于 TCP 的架构带到 Web 上,并允许从几乎任何地方访问您的应用。您还了解了如何使用 VNC 通过互联网远程控制系统。

伴随这些能力而来的是安全性的挑战和复杂性。网络安全是一个既重要又被误解的话题。尽管软件系统的各个方面都可以在考虑安全性的情况下进行设计,但是由于许多不同组件的交互作用,与安全性相关的系统属性可能会非常复杂。增强系统的安全性意味着将技术应用于软件系统以抵御威胁。

Web 安全的话题跨越网络和浏览器安全,包括应用级安全,甚至操作系统的安全。当您使您的用户能够通过 Internet 访问系统时,您的资产(您的数据库、服务器、应用等)就暴露在所有类型的预期和非预期风险中。网络安全技术可以减轻和解决互联网上的威胁。

WebSocket 标准通过提供未加密和加密的传输,以及将 WebSocket 定义为所有现有安全协议都可以在其中运行的框架,来处理核心安全性。我们无法证明 WebSocket 本身拥有所谓的“安全性”或提供任何最终的防弹配方。但是,我们可以检查与 WebSocket 相关的特定类型的威胁,并推荐最佳实践来帮助编写和部署更安全的应用。

本章详细描述了 WebSocket 安全性,解释了在协议和 API 设计中做出的安全决策,并推荐了部署 WebSocket 服务和应用的实践。有许多 Web 安全资源可供您阅读,尤其是那些与您想用 WebSocket 分层的任何协议相关的资源。在这一章中,我们主要关注与 WebSocket 直接相关的安全方面。

WebSocket 安全概述

在决定使用 WebSocket 时,通过 Web 部署应用会带来您必须考虑的安全挑战。这种挑战包括对服务器的攻击,这些攻击可能利用 WebSocket 服务器中的缺陷来获得对它们的控制。还有拒绝服务攻击,这种攻击试图使系统用户无法使用资源。这种攻击的目标是阻止网站、服务或服务器高效工作——暂时甚至无限期地。

允许用户访问您的 web 应用也会使您的用户受到攻击。恶意的人和邪恶的机器人不断试图复制、删除和修改珍贵的用户数据。这些攻击中的一些可能依赖于模仿,而另一些可能是更被动的窃听和拦截。这些常见的威胁通常通过身份验证和加密通信来缓解。

除了这些众所周知的攻击类型之外,还有针对那些既没有使用也没有部署 WebSocket 的人的非故意和不明确的攻击。这些例子包括混淆 WebSocket 流量和 HTTP 流量的遗留代理和监控系统。

我们在第三章中研究的许多 WebSocket 协议设计选择在安全性方面是有意义的,并被添加来减轻特定的攻击。毕竟,如果 WebSocket 的目的是在两个端点之间打开一个自由流动的字节管道,那么其他一切都是装饰性的。碰巧的是,这些陷阱中的一些是挫败非常特殊类型的攻击所必需的。这些威胁可能会影响协议的用户,或者更奇怪的是,碰巧在同一个网络上的无辜旁观者。

表 7-1 描述了其中一些安全问题,并简要描述了 WebSocket 协议的一些特性是如何专门设计来缓解这些攻击的。我们将在随后的小节中更深入地研究这些领域,并探索更高级别的 WebSocket 安全领域,如身份验证和应用级安全。

表 7-1。web socket API 和协议解决的攻击类型

这种攻击 。。。是由这个 WebSocket API 或协议特性解决的
拒绝服务 原始标题
通过连接泛滥拒绝服务 使用源标头限制新连接
代理服务器攻击 掩饰
中间人,偷听 WebSocket 安全(wss://)

WebSocket 安全特性

在我们研究 WebSocket API 和协议解决特定安全领域的方面之前,让我们回顾一下 WebSocket 握手。WebSocket 握手包含几个有助于在 WebSocket 连接上建立安全性的组件。

正如我们在第三章中所描述的,WebSocket 连接从一个包含特殊头的 HTTP 请求开始。该请求的内容是为了安全性和与 HTTP 的兼容性而精心设计的。回顾一下,清单 7-1 是一个客户端发送 WebSocket 握手的例子:

清单 7-1 。客户端发起 WebSocket 握手

Request
GET /echo HTTP/1.1
Host: echo.websocket.org
Origin:http://www.websocket.org
Sec-WebSocket-Key: 7+C600xYybOv2zmJ69RQsw==
Sec-WebSocket-Version: 13
Upgrade: websocket

服务器发回一个响应,如清单 7-2 所示。

清单 7-2 。服务器响应并完成 WebSocket 握手

Response
101 Switching Protocols
Connection: Upgrade
Date: Wed, 20 Jun 2012 03:39:49 GMT
Sec-WebSocket-Accept: fYoqiH14DgI+5ylEMwM2sOLzOi0=
Server: Kaazing Gateway
Upgrade: WebSocket

握手中需要注意的两个重要区域是 Origin 头和Sec-头,我们将在下一节中研究它们。

原点表头

WebSocket 协议(RFC 6455 ) 与另一个文档同时发布,该文档定义了 WebSockets 在 Web 上安全部署所必需的关键思想:origin。起源概念出现在早期的规范中,如跨文档消息传递和跨域资源共享,现在被广泛使用。然而,为了让 WebSocket 标准有效且安全地推广到 Web 上,需要更精确地定义起源概念。RFC 6454 通过定义和描述相同起源策略背后的原则来实现这一点,更重要的是,定义和描述起源报头。

image 完整的 RFC 6454 规范见http://www.ietf.org/rfc/rfc6454.txt

源由方案、主机和端口组成。在序列化形式中,源看起来像一个 URL:方案和主机由://分隔,冒号在端口前面。对于端口与方案的默认端口相匹配的源,该端口将被省略。

image 注意由于大多数序列化的起源使用端口 80,这与默认的 HTTP 端口相匹配,所以该端口通常从起源中省略。典型的序列化来源如下:http://example.com

图 7-1 显示了一个示例源,包含一个方案、主机和端口。

9781430247401_Fig07-01.jpg

图 7-1 。原点图

如果两个原点的任何组件不同,浏览器会将这些原点视为完全独立的原点。浏览器可以强制执行一致的规则,以便在来源之间进行通信和共享数据。例如,不同来源的应用可以使用postMessage() API 、进行通信,但是它们能够基于发送者和接收者的来源来确定消息的范围。

Origin 取代了旧的、不太标准化的、更复杂的规则,有时被称为“同域策略”同域政策并不全面:页面仍然可以热链接图片并嵌入来自任何来源的 iframes。这个策略包括 referer 头的规则,除了规范拼写错误之外,还包括 URL 路径,因此泄露了太多信息。因此,引用者经常是隐藏的,并且不能依赖于访问控制。对于跨域脚本,对于仅在端口或方案上有所不同的部分匹配来源的页面,实施非标准规则。简而言之,同域政策一团糟。

origin 模型为 web 应用清理了所有的跨域规则。它将源定义为三元组(方案、主机和端口)。如果两个 URL 在这三个方面有任何不同,那么它们就有不同的来源。

origin 模型还使得托管公共和半公共服务成为可能。例如,一个服务器可以允许多个源,允许连接与整个 Web 上的应用交换数据。公共服务甚至可以尝试默认允许所有来源,只阻止那些已知有问题的来源。因此,origin 比旧的同域策略更加灵活,在旧的同域策略下,所有不相似的 origin 都被认为是恶意的,并在默认情况下被阻止。

缓解拒绝服务

Origin 允许接收方拒绝不想处理的连接。Web 服务器可以检查传入请求的来源标头,并选择不处理来自未知或可能恶意来源的连接。这种能力对于减少拒绝服务(DoS)攻击非常有帮助。

针对网络服务器的拒绝服务攻击有很多种。一些 DoS 攻击开始于由成千上万台受损电脑组成的僵尸网络;这种攻击完全在攻击者的控制之下。有些人可能认为僵尸网络只是现代互联网生活的一部分。但是,web origin security 还可以直接应对其他攻击。网络平台使得滥用网络功能发动 DoS 攻击变得更加困难。在 WebSocket 的情况下,因为接收服务器可以使用 origin 头来验证传入请求的来源,所以 DoS 攻击更加难以完成。服务器可以禁止来自未知或攻击源的连接,通过拒绝连接来节省服务器资源。

浏览器发出的所有 WebSocket 请求中都包含正确的 origin 头。完全不能在浏览器中运行的应用怎么办?您可能会注意到,没有什么可以阻止您在控制台应用中打开一个套接字,并编写您喜欢的任何源代码。服务器并不真的知道带有特定 origin 头的请求来自 web 应用;他们只知道一个请求不是来自不同来源的 web 应用。人们反复问的一个问题是,如果 origin 如此容易被欺骗,它提供了什么安全保障?理解答案需要理解 WebSockets 的真正本质,如下一节所述。

什么是 WebSocket(从安全角度来看)?

如果您已经在本书中读到这里,您就会知道在您的应用中使用 WebSocket 所带来的许多好处。正如第一章中所描述的,WebSocket 有许多可取的特性。WebSocket 是一个简单、标准、低开销的全双工协议,可用于构建可伸缩的、接近实时的网络服务器。如果你是一个聪明的计算学生,或者回忆一下早期的日子,你会知道这些特征中的大多数同样适用于简单、纯粹的 TCP/IP。也就是说,它们是套接字(尤其是 SOCK_STREAM)的特征,而不是 WebSockets 的特征。那么,为什么要把“网络”加到“插座”上呢?为什么不简单地在 TCP 之上构建传统的互联网应用呢?

要回答这个问题,我们需要区分“非特权”和“特权”应用代码。无特权代码是在已知来源内运行的代码,通常是在网页内运行的 JavaScript。在 Web 安全模型中,TCP 连接不能被“非特权代码”安全地打开如果允许无特权的应用代码打开 TCP 连接,脚本就有可能发起带有伪造标头的 HTTP 请求,这些标头看起来似乎来自不同的来源。如果相同的脚本可以通过在 TCP 上重新实现 HTTP 来绕过这些规则,那么以这种方式欺骗报头的能力将使得管理脚本如何进行 HTTP 连接的规则变得毫无意义。允许来自 web 应用的 TCP 连接将打破原始模型。

因此,由非特权代码形成的 WebSocket 连接必须遵循与 AJAX 和其他允许非特权代码的网络功能相同的模型。HTTP 握手将连接初始化置于浏览器的控制之下,并允许浏览器设置源头和保留源模型所需的其他头。通过这种方式,WebSocket 允许应用在与 HTTP 应用、服务器和沙箱规则共存的同时,通过轻量级双向连接执行互联网风格的联网。

从特权代码形成的 WebSocket 连接通常可以打开任何网络连接;这种能力不是问题(或者至少对 Web 来说不是问题),因为特权应用一直开放使用任何网络连接,并且必须由用户安装和执行。本机应用不在 web 起源中运行,因此起源规则不需要应用于由特权代码形成的 WebSockets。

不要让用户困惑

过去,授予非特权代码网络访问权的一种方法是,当应用请求时,提示用户授予选择性的网络使用权限。理论上,这种方法为同样的问题提供了解决方案。当用户授予选择性权限时,虽然应用会将知道应用应该如何运行的责任放在用户身上,但是当用户明确允许时,代码可以被允许进行网络连接。实际上,用户并不知道向代码授予权限的影响,大多数用户会愉快地点击 OK。用户只是希望应用能够正常工作。

与安全相关的提示通常令人讨厌、困惑,并且经常被忽略,从而导致严重的后果。WebSocket 方法拒绝应用打开非中介 TCP 连接;但是,它提供了与典型网络相同的功能,其安全性更好,并且不会因为来源而对用户的用户界面产生影响。Origin 让接收服务器拒绝连接,而不是要求用户允许连接。

节流新连接

尽管 origin 报头可以防止在 HTTP 层建立大量连接,但是在 TCP 层打开的大量连接仍然存在潜在的 DoS 威胁。即使开放的 TCP 连接不携带任何数据成本资源,也有可能出现大量客户端使服务器不堪重负的情况,即使这些连接最终会被服务器拒绝。为了防止连接过载,WebSocket API 要求浏览器限制新连接的打开。正如你在第二章中看到的,每次调用 WebSocket 构造函数都会产生一个新的、开放的网络套接字。浏览器限制底层 TCP 套接字打开的速率,这反过来防止 TCP 连接的洪流打开到不接受它们作为 WebSocket 升级的主机,并且不会过度减慢合法地想要打开到同一主机的一个或少量连接的应用。

带有“Sec-”前缀的标题

在 WebSocket 开始握手中,有几个必需的 HTTP 头,如清单 7-1 和 7-2 所示。其中一些头文件在 web 平台上很普遍,比如 origin,而其他一些新的头文件是为了支持 WebSocket 而引入的。新的头经过精心选择和命名,以防止滥用 AJAX APIs 来欺骗 WebSocket 请求。

客户端向服务器发送 HTTP 请求,要求将协议升级到 WebSocket。如果服务器支持这种升级,它会用相应的头进行响应。正如在第三章中所解释的,为了成功完成升级,其中一些头文件是必需的。服务器和浏览器都强制执行这种交换和报头确认。在这些报头中,有些以前缀“Sec-”开头,在 RFC 6455 中有描述。这些头在开始的 WebSocket 握手中使用(见表 3-2 对这些Sec-头的完整描述):

  • Sec-WebSocket-Key
  • Sec-WebSocket-Accept
  • Sec-WebSocket-Extensions
  • Sec-WebSocket-Protocol
  • Sec-WebSocket-Version

在 XMLHttpRequest 规范中,浏览器为平台级使用保留了几个头名称,因此在应用中是禁止使用的。这些头名包括以 Sec-开头的头名,以及常见的安全关键头名,如 referer、host 和 cookie。

RFC 6455 中定义的新报头都带有前缀Sec-。这样命名这些前缀是因为浏览器代表应用打开 WebSocket 连接,但是握手中的低级、安全敏感参数是应用代码禁止使用的。这有效地锁定了升级请求,使得它们只能通过 WebSocket 构造函数这样的 API 而不是通过 XMLHttpRequest 这样的通用 HTTP APIs 来进行。

WebSocket 安全握手:接受密钥

您可能还记得我们对 WebSocket 协议的讨论,WebSocket 连接的成功是基于服务器的响应。服务器必须用 101 响应代码、WebSocket 升级头和Sec-WebSocket-Accept头来响应初始 WebSocket 握手。Sec-WebSocket-Accept响应头的值来自于Sec-WebSocket-Key请求头,并且包含一个特殊的密钥响应,该响应必须与客户端期望的完全匹配。

客户端和服务器之间需要交换Sec-WebSocket-KeySec-WebSocket报头的原因并不明显。这些密钥只在连接初始化期间使用,很容易被截获,并且显然不能为 WebSocket 客户端或服务器提供保护。这些钥匙能保护什么?这些密钥实际上保护了非 ?? 的 WebSocket 服务器,并消除了跨协议攻击的可能性。跨协议攻击的例子是那些向非 WebSocket 服务器发出巧尽心思构建的 WebSocket 请求以建立连接或以其他方式利用它们的攻击,以及通过与另一个协议建立巧尽心思构建的连接来混淆期望一个协议的服务器。为了防止使用 WebSocket 的跨协议攻击,握手要求服务器转换客户端提供的密钥。如果服务器没有按预期回复,客户端将关闭连接。WebSocket RFC 包含一个全球唯一标识符(GUID ),这是一个用来标识协议而不是连接或用户或系统中任何其他参与者的神奇密钥。GUID 是258EAFA5-E914-47DA-95CA-C5AB0DC85B11

服务器读取Sec-WebSocket-Key报头的值,并执行以下步骤:

  1. 服务器添加 GUID: 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  2. 服务器使用 SHA1 哈希转换结果
  3. 服务器使用 Base64 编码转换结果
  4. 服务器将结果作为Sec-WebSocket-Accept头的值发送回来

通过对所提供的密钥执行特定的转换,服务器证明它特别理解 WebSocket 协议,因为不知道散列的服务器不是真正的 WebSocket 服务器。这种转换避免了直接的跨协议攻击,因为真正的 WebSocket 客户端和服务器将坚持只在它们之间进行对话。

HTTP 代理和屏蔽和

在第二章和第三章中,我们讨论了 WebSocket 框架,它包含了 WebSocket 消息。从浏览器发送到服务器的 WebSocket 帧被屏蔽,以混淆帧的内容,因为拦截代理服务器会被 WebSocket 流量混淆。在第三章的中,我们讨论了屏蔽 WebSocket 框架如何提高与现有 HTTP 代理的兼容性。然而,屏蔽还有另一个与安全有关的不寻常且微妙的原因。

与常规的 HTTP 请求-响应流量不同,WebSocket 连接可以长时间保持开放。在较旧的体系结构中,代理服务器被配置为允许这样的连接,并且可以很好地处理流量,但是它们也会干扰 WebSocket 流量并引起麻烦。

代理服务器充当客户端和另一个服务器之间的中介,通常用于监控流量,如果连接打开时间过长,有时会关闭连接。代理服务器可能会选择关闭长期 WebSocket 连接,因为代理服务器会将这些连接视为试图与无响应的 HTTP 服务器连接。

图 7-2 显示了一个简单的网络拓扑的例子,包括 WebSocket、代理服务器和 web 应用。这里,浏览器中的客户端应用使用 WebSocket 连接访问后端基于 TCP 的服务。其中一些客户端位于企业内部网中,受企业防火墙保护,并配置为通过显式代理服务器访问 Web(见图 7-2);这些代理服务器可以缓存内容并提供一定程度的安全性。其他客户端应用直接访问 WebSocket 服务器。在这两种情况下,客户端请求可以通过透明代理服务器进行路由。

9781430247401_Fig07-02.jpg

图 7-2 。使用代理服务器的网络拓扑

图 7-2 显示了三种类型的代理服务器:

  • 转发代理服务器:通常由服务器管理员安装、配置和控制。转发代理服务器将从内部网发出的请求定向到互联网。
  • 反向代理服务器:通常由服务器管理员安装、配置和控制。反向代理服务器(或防火墙)通常部署在服务器前面的网络 DMZ 中,执行安全功能以保护内部服务器免受来自互联网的攻击。
  • 透明代理服务器:通常由网络运营商控制。透明代理服务器通常拦截网络通信,用于缓存或阻止公司内部网用户出于特定目的访问 Web。网络运营商可以使用透明代理服务器来缓存经常访问的网站,以减少网络负载。

所有这些拦截代理服务器都可能被 WebSocket 流量混淆,对于透明代理服务器来说尤其如此。例如,攻击者可能会毒害透明代理服务器上的 HTTP 缓存。HTTP 缓存中毒是一种攻击,在这种攻击中,攻击者对 HTTP 缓存进行控制,以提供危险的内容来代替请求的资源。在一组研究人员撰写了一篇论文,概述了对使用 HTTP 升级请求的透明拦截代理的理论攻击之后,缓存中毒成为 WebSocket 标准化期间的一个主要问题。这篇名为【与自己交谈以获取乐趣和利益】的论文(黄、陈、巴斯、瑞斯寇拉、&杰克森,2011)让工作组相信,如果没有针对这些可能的攻击的保护,WebSocket 标准化是危险的。如何预防中毒?经过工作组的激烈辩论,屏蔽被添加到 WebSocket 协议中。屏蔽是一种隐藏(但不加密)协议内容的技术,以防止透明拦截代理中的混乱。正如在《??》第三章中所讨论的,屏蔽通过将内容与几个随机字节进行异或来转换从浏览器发送到 WebSocket 服务器的每个消息的有效载荷。

之前我们提到过,插件和扩展如果请求权限可以打开套接字,安装的应用一直使用任意流量的网络连接。是什么阻止了这种代理背后的人打开终端,手动创建这种网络流量?答案是:完全没有。屏蔽不能修复代理中的问题,尽管它不会加剧现有的问题。在 Web 上,没有安装应用,你只需浏览一下就可以运行来自不同来源的数千个脚本,一切都被放大了。Web APIs 必须更加偏执。

与 origin 一样,屏蔽也是一种安全功能,不需要针对窃听进行加密保护。如果双方和任何中间人愿意,他们都可以理解屏蔽的有效载荷。然而,对于不理解屏蔽有效载荷的中间人,他们受到严格保护,不会将 WebSocket 消息的内容误解为 HTTP 请求和响应。

安全 WebSocket 使用 TLS(你也应该!)

正如我们在第三章中讨论的,WebSocket 协议定义了 WebSocket ( ws://)和 WebSocket Secure ( wss://)前缀;两种连接都使用 HTTP 升级机制来升级到 WebSocket 协议。传输层安全性(TLS)是访问 URL 以https://开头的安全网站时使用的协议。TLS 在传输过程中保护数据并验证其真实性。WebSocket 安全连接通过使用 WebSocket 安全(WSS) URL 方案wss://的隧道通过 TLS 来保护。通过保护与 TLS 的 WebSocket 通信,您可以保护网络通信的机密性、完整性和可用性。在这一章中,我们关注与 WebSocket 安全性相关的机密性和完整性,并在第八章中从部署的角度探讨 TLS 的好处。

image 注意 TLS 有自己的 RFC,由 IETF 定义:http://tools.ietf.org/html/rfc5246

您可以使用普通的、未加密的 WebSocket 连接(前缀为ws://)来进行测试,甚至是简单的拓扑。如果您在网络上部署服务,有线级加密的好处是巨大的,缺点相对较小。

随着 TLS 协议和现代机器的改进,在 Web 上使用加密的主要历史缺陷都大大减少了。在过去,你可能会选择不使用 HTTPS,因为高 CPU 成本,缺乏虚拟主机,以及新连接的启动时间慢。现代的改进减轻了这些担忧。

部署 TLS 也有一些令人愉快的副作用。加密的 WebSocket 流量通常通过代理运行得更顺畅。加密阻止代理检查流量,所以它们通常只让字节通过,而不试图缓冲或改变流量。有关部署加密 WebSocket 服务的更多信息,请参见第八章。

就像 WebSocket 在升级到 WebSocket 之前以 HTTP 握手开始一样, WebSocket Secure (WSS) 握手以 HTTPS 握手开始。HTTPS 和 WSS 协议非常相似,都运行在 TCP 连接上的 TLS 之上。为 WebSocket 网络流量配置 TLS 加密的方式与为 HTTP 配置相同:使用证书。使用 HTTPS,客户端和服务器首先建立安全连接,然后才开始 HTTP 协议。类似地,WSS 建立安全连接,开始 HTTP 握手,然后升级到 WebSocket wire 协议。这样做的好处是,如果您知道如何为加密通信配置 HTTPS,那么您也知道如何为加密 WebSocket 通信配置 WSS。

图 7-3 顶部的电缆显示了 HTTPS 不是一个独立的协议,而是运行在 TLS 连接上的 HTTP 的组合。通常,HTTPS 使用不同于 HTTP 的端口(HTTPS 的默认端口是 443,HTTP 的默认端口是 80)。HTTP 直接运行在 TCP 上,HTTPS 运行在 TLS 上,而 TLS 又运行在 TCP 上。

图底部的电缆显示,WebSocket 安全(WSS)连接也是如此。WebSocket (WS)协议运行在 TCP 上(像 HTTP 一样),WSS 连接运行在 TLS 上,而 TLS 又运行在 TCP 上。WebSocket 协议与 HTTP 兼容,因此 WebSocket 连接使用相同的端口:WebSocket 默认端口是 80,WebSocket Secure (WSS)默认使用端口 443。

9781430247401_Fig07-03.jpg

图 7-3 。HTTP、HTTPS、WS 和 WSS

认证

为了确认通过 WebSocket 连接到我们服务器的用户的身份,WebSocket 握手可以包含 cookie 头。使用 cookie 头允许服务器使用用于验证 HTTP 请求的相同 cookie 进行 WebSocket 验证。

在写这本书的时候,浏览器不允许来自 WebSocket API 的其他形式的 HTTP 认证。有趣的是,API 并不禁止这些机制;它们不能在浏览器上运行。例如,如果服务器响应 WebSocket 升级请求,要求状态 401 身份验证,浏览器将简单地关闭 WebSocket 连接。这个模型中的假设是,在应用尝试打开 WebSocket 连接之前,用户已经通过 HTTP 登录到应用。

或者,身份验证可以在 WebSocket 升级完成后通过应用层协议进行。XMPP 和 STOMP 之类的协议在这些层中内置了标识用户和交换凭证的语义。可以部署未经身份验证的 WebSockets,但需要在下一个协议层进行身份验证。在下一节“应用级安全性”中,我们还将讨论如何在应用协议级实施授权。

应用层安全

应用级安全性规定了应用如何保护自己免受可能暴露隐私信息的攻击。这个安全级别保护应用公开的资源。如果您使用 XMPP、STOMP 或高级消息队列协议(AMQP) 等标准协议,那么您可以在基于 WebSocket 的系统上利用应用级安全性。配置权限是针对服务器的。

在第五章中,我们使用了 Apache ActiveMQ 消息代理来说明如何使用 STOMP over WebSocket 构建应用。在这里,我们将继续构建 ActiveMQ 配置并限制对其资源的访问,我们将通过实现 Apache ActiveMQ 附带的示例 WebSocket 应用的安全性来实现这一点。

在本节中,我们回顾两种应用安全措施。首先,我们来看一个简单的身份验证插件,它要求用户在访问 ActiveMQ 之前进行身份验证。然后,我们看一下如何配置授权来保护特定的队列和主题。

在我们开始之前,让我们确保您可以在不提供凭据的情况下访问演示,以确保您尚未配置应用身份验证和授权。使用以下命令,启动 ActiveMQ:

$> bin/activemq console

导航到 ActiveMQ 欢迎页面,默认情况下位于http://0.0.0.0:8161/。点击查看一些 web 演示链接,并打开 WebSocket 示例。这个演示的默认 URL 是:http://0.0.0.0:8161/demo/websocket/index.html

将服务器 URL 字段中的主机名从localhost修改为0.0.0.0,并点击连接。如图图 7-4 所示,你现在应该被连接上了。

9781430247401_Fig07-04.jpg

图 7-4 。Apache ActiveMQ 配置的没有认证

因为 ActiveMQ 没有配置为执行身份验证,所以您输入的任何凭据都会被忽略。您可以通过将登录和密码字段更改为任意值,然后再次单击连接来确认此行为。你应该还能连接。现在,让我们向应用添加身份验证。

应用认证

配置后,身份验证会阻止用户访问 ActiveMQ,除非他们提供正确的凭据。让我们回顾一下如何配置身份验证,以及如何为用户定义凭据。

清单 7-3 显示了一个示例配置片段,您可以将其添加到我们在第五章中使用的配置中。当您将它添加到 Apache ActiveMQ 安装目录中的conf/activemq.xml文件中时,您限制了用户对系统的访问,ActiveMQ 用用户名和密码询问用户。

image 注意你可以为 Apache ActiveMQ 定义各种配置文件。为了使我们的例子简单,这里我们修改默认的例子。有关其他 Apache ActiveMQ 配置,请参见 ACTIVEMQ_HOME/conf 目录。

清单 7-3 。样本 Apache ActiveMQ 配置

<plugins>
<simpleAuthenticationPlugin>
<users>
<authenticationUser username="system"
password="${activemq.password}"
groups="users,admins"/>
<authenticationUser username="user"
password="${guest.password}"
groups="users"/>
<authenticationUser username="guest"
password="${guest.password}" groups="guests"/>
</users>
</simpleAuthenticationPlugin>
</plugins>

清单 7-3 在conf/activemq-security.xml文件中也是一个例子。

让我们用新的配置启动 Apache ActiveMQ 消息代理。在 ACTIVEMQ_HOME 目录中,输入以下内容:

$> bin/activemq console

现在,当您尝试使用演示提供的相同用户名和密码访问示例应用时,会导致系统拒绝用户登录,如图图 7-5 所示。这是因为默认情况下,登录和密码字段的预填充值是guest,而您刚刚添加的配置引用了${guest.password}属性,正如在ACTIVEMQ_HOME/conf/credentials.properties文件中指定的那样。

9781430247401_Fig07-05.jpg

图 7-5 。Apache ActiveMQ 拒绝使用新配置的用户登录

提供正确的用户名和密码组合将允许用户访问系统。访客用户的默认密码是password(定义见ACTIVEMQ_HOME/conf/credentials.properties )

清单 7-4 显示了认证插件配置中的一行,告诉 Apache ActiveMQ 消息代理用户guest有他或她自己的密码,并且该用户是guests组的一部分。

清单 7-4 。为用户 Guest 设置密码和组

<authenticationUser username="guest" password="${guest.password}"
groups="guests"/>

申请授权

身份验证成功后,您希望授予对某些应用资源的访问权限,同时拒绝对其他一些资源的访问权限。在本节中,我们将回顾您需要做些什么来授予用户和组对特定队列和主题的访问权限。

清单 7-5 显示了一个配置,当添加到the ACTIVEMQ_HOME/conf/activemq.xml文件时,限制对消息代理目的地的故意访问。该配置显示了如何根据应用要求启用或禁用用户访问。类似于认证片段,authorizationPlugin必须被<plugins>标签包围。插件标签中授权和认证的顺序是不相关的。

清单 7-5 。限制对消息代理目的地的故意访问

<authorizationPlugin>
<map>
<authorizationMap>
<authorizationEntries>
<authorizationEntry queue=">" read="admins"write="admins" admin="admins" />
<authorizationEntry queue="USERS.>" read="users" write="users" admin="users" />
<authorizationEntry queue="GUEST.>" read="guests"
write="guests,users" admin="guests,users" />
<authorizationEntry queue="TEST.Q" read="guests" write="guests" />
<authorizationEntry topic=">" read="admins" write="admins" admin="admins" />
<authorizationEntry topic="USERS.>" read="users" write="users" admin="users" />
<authorizationEntry topic="GUEST.>" read="guests" write="guests,users" admin="guests,users" />
<authorizationEntry topic="ActiveMQ.Advisory.>" read="guests, users" write="guests,users" admin="guests,users"/>
</authorizationEntries>
</authorizationMap>
</map>
</authorizationPlugin>

清单 7-5 中的示例配置指定admins拥有对所有队列和主题的完全访问权,而来宾只能访问具有GUEST 的队列和主题。在自己的名字前加前缀。

重新启动 ActiveMQ 以获取配置更改。当您在 web 浏览器中重新加载示例演示应用时,请确保将默认密码更改为值password,然后单击 Connect。用户将被验证,但不能发送或接收消息,因为主题名称没有正确的前缀。

图 7-6 显示用户可以连接到系统,但不能发送到test队列。

9781430247401_Fig07-06.jpg

图 7-6 。用户成功连接但无法发送消息

现在,将目的地字段设置为/queue/GUEST.test,然后点击连接。图 7-7 显示了成功登录的结果,用户现在被授权在该队列上发送和接收消息。

9781430247401_Fig07-07.jpg

图 7-7 。用户成功连接并被授权接收消息

用户现在能够发送和接收消息,因为您在 ActiveMQ 配置中设置了一个授权策略,允许用户,即来宾组的一部分,读取、写入和管理任何带有前缀GUEST的队列和主题。清单 7-6 显示了授权策略。

清单 7-6 。ActiveMQ 中的授权策略

<authorizationEntry queue="GUEST.>" read="guests" write="guests,users"
admin="guests,users" />

image 注意 Apache ActiveMQ 利用目的地通配符为联合名称层次结构提供支持。

正如您所看到的,您可以简单地通过配置后端消息代理来控制应用的安全性。增强应用资源的安全性进一步加强了 WebSocket 上的安全模型,从 WebSocket 上的后端服务器,一直到浏览器中的应用。

摘要

安全性是 Web 应用部署的一个极其重要的方面,对于 WebSocket 应用来说同样重要。在本章中,我们研究了与 WebSocket 相关的 Web 安全领域,以及如何使用 TLS 等常用的安全协议、WebSocket 中内置的屏蔽等功能以及 origin 头(其定义专门针对 WebSocket 规范进行了细化)来解决这些问题。最后,我们通过一个示例演示了如何通过应用身份验证和授权来实现应用级别的安全性,从而从源头保护资源。

在下一章中,我们将进一步探讨与部署相关的安全性,并讨论当您决定将 WebSocket 应用部署到 Web 时需要考虑的事项。

八、部署考虑事项

在构建、保护和测试任何 web 应用之后,下一个合乎逻辑的步骤就是部署它。在部署 WebSocket 应用时,您必须考虑的许多事项与任何 web 应用类似。在这一章中,我们将重点关注 web 应用部署的各个方面,尤其是在部署 WebSocket 应用时应该考虑的方面。

在部署应用时,您必须考虑许多因素,尤其是对企业而言,例如业务需求、客户端将如何与应用交互、应用使用的信息、在任何给定时间将使用应用的客户端数量等等。一些应用可能需要高可用性,并且必须支持许多并发连接,而其他应用可能更强调性能和低延迟。对于 WebSocket 应用,您需要提供相同的需求,记住 WebSocket 协议的本质和您正在构建的应用的类型。在这一章中,我们将研究部署的一些主要方面,因为它们特别与 WebSocket 相关,如 WebSocket 仿真、代理和防火墙、负载平衡和容量规划。

WebSocket 应用部署概述

在部署任何 web 应用时,您都需要考虑一些一般要求,例如将使用您的应用的各种浏览器、应用的类型、服务器必须处理的流量的性质,以及应用是由服务器驱动还是由用户交互驱动。现在,您的脑海中充满了可以使用 WebSocket 构建的各种新应用,您需要在部署它们时考虑这些应用需求。

例如,你可以想象一个基于 WebSocket 的消息传递应用(使用 STOMP over WebSocket,正如我们在第五章中所描述的),其中你的应用必须支持数千个——不,是数万个——并发连接。您的应用可能是一个股票投资组合应用,其中您的用户可以跟踪每天发生的数百万次股票交易。为了使应用有用,这些数据必须立即实时刷新;因此,您可能会看到一个系统,其中服务器使用全双工连接将连续的股票信息传输到用户的浏览器或移动设备,几乎没有用户交互。在另一种情况下,您可能希望使用 WebSocket 创建一个可定制的视频流应用,其中大量数据(几分钟长的视频文件)可以通过连接进行流式传输,但一次只能传输到几千个客户端;这种流量在一天中可能是零星的,在一天中的特定时间达到高峰,并且用户请求、共享和发布视频的交互量很大。每个场景都非常适合 WebSocket,并且每个场景都有不同的部署需求。

在本书的前几章中,您还了解了通过 WebSocket 使用标准协议的不同方式。您可以做出的选择之一是,您可以选择在您的服务器中启用 WebSocket(例如,您可以让您的 XMPP 聊天服务器使用 WebSocket ),或者选择使用位于基于 TCP 的服务器和您的客户端之间的网关,但允许您在 WebSocket 上放置标准协议以利用全双工连接。在每一种情况下,您可能需要考虑支持 WebSocket 的后端服务器或 WebSocket 网关将如何处理各种客户端连接。

应用部署WebSocket 应用部署之间的差别并不大,但是在部署您的 WebSocket 应用时,有一些地方需要考虑。

WebSocket 仿真和回退

虽然现代浏览器原生支持 WebSocket,但仍然有许多没有原生 WebSocket 支持的旧版本浏览器被广泛使用,其中许多是在企业环境中或在严格控制浏览器和版本的业务需求下。作为一名开发人员,您通常无法控制用于访问应用的浏览器的类型,但是您仍然希望能够适应尽可能广泛的受众。通过使用协作客户端库和服务器之间的其他通信策略,有多种方法可以实现 WebSocket 功能的“仿真”。作为最后的手段,还可以选择使用另一种通信技术。

插件〔??〕

建立全双工通信的一种方法是使用插件。一个常见的插件是 Adobe Flash,它可以让你打开 TCP 套接字。虽然大多数桌面浏览器都倾向于安装 Flash,但如果没有,用户必须显式下载,这会影响用户体验。由于 Flash 和应用代码之间昂贵的通信,使用插件进行通信也会影响应用的性能。更糟糕的是,使用 Flash sockets 会导致您的应用在连接时挂起三秒钟。此外,请记住,Adobe Flash 在流行的 iOS、Android 和 Windows RT 环境中并不完全受支持。这种支持的缺乏意味着随着浏览器继续远离基于插件的可扩展性,基于插件的回退策略变得越来越不可行。

多填充物

插件的一个可行替代方案是 polyfill,它是一个使用传统浏览器功能实现标准 API 的库。Polyfills 允许开发人员在创建应用的同时瞄准新的 web 标准,并且仍然能够接触到使用旧浏览器的用户。对于各种 HTML5 功能,包括图形、表单和数据库,存在许多聚合填充。Polyfills 可以使用多种策略来实现标准 API。例如,Kaazing 为 WebSocket API 提供了一个 polyfill,它使用 secret sauce 流技术,并为在不支持 WebSocket 的浏览器上运行的 WebSocket 应用提供了一个后备解决方案。

Modernizr ,是一个 HTML5 最佳实践的开源项目,它维护着一个 wiki,其中有一个最新的 polyfills 列表及其对各种 HTML5 技术的描述,包括在https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills的 WebSocket。

不同的抽象层

还有其他像 Socket.io 这样的库,它们使用 WebSocket 和 Comet 技术来公开单个 API。其中一些库具有与标准 WebSocket API 相同的 API,因此是 polyfills。其他库包括不同于 WebSocket API 的 API,但是使用 WebSocket 作为传输策略来提供不同的通信抽象。这种技术并不一定使它们不如 polyfills 更理想,但是针对标准接口编写的代码更具可移植性,并且在预期有一天不再需要后备库时是未来的证明。

即使是采用 Comet 技术的最好的回退实现也有其缺点。从本质上讲,这些是在应用中使用 WebSocket 作为传输层协议的好处的另一面。仿真对于确保与传统浏览器和不利网络的连接是必不可少的,但是当您诉诸回退策略时,了解您正在失去什么也是很重要的。许多原因是迫使 WebSocket 首先被开发的原因。在选择和设计回退策略时,您可能希望记住这些模拟技术:

  • 是非标准: 正如我们在第一章中所描述的,这些回退选项是非标准的,这也是 WebSocket 被创建的原因之一。使用这些非标准的技术,不同的 WebSocket 仿真客户端和服务器之间无法相互通信。
  • 提供降低的性能: 高性能的 Comet 实现只能单向传输数据:从服务器到浏览器。即使通过 AJAX 实现的最佳 WebSocket 仿真也无法将数据从浏览器传输到服务器。
  • 有浏览器连接限制:浏览器限制每台主机的 HTTP 连接数。彗星连接不受此限制。
  • 拥有复杂的(或无功能的)跨起源部署 : WebSocket 从第一天起就内置了起源安全性。跨源 AJAX 需要额外的配置才能在遗留浏览器上工作。

代理和其他网络中介

在第七章中,我们讨论了代理服务器以及它们与 WebSocket 安全性的关系。代理属于“中介体”的范畴,它是位于 web 应用和服务器之间的网络中介。

有两种截然不同的类中间体会影响部署:位于服务器和互联网之间的中间体,以及位于用户和互联网之间的中间体。在服务器端,您或您的组织通常控制防火墙和反向代理,它们是服务器基础结构的一部分。添加这些服务器端中介是为了支持您的基础设施或实施安全策略。

在客户端,用户通常位于防火墙和转发代理之后。它们的连接通过这些中介到达网络。除了一些封闭的环境,您无法控制用于连接到服务器的网络。但是,您可以在部署 WebSocket 服务器时做出决定,使通过这些网络的连接更顺畅、更频繁地成功。

当考虑部署您的 WebSocket 应用时,您将需要考虑能够处理客户端和服务器之间流量的各种可能的中介。

反向代理和负载平衡

反向代理是代表一台或多台服务器接受 web 客户端连接的特定类型的服务器。反向代理有多种用途,包括隐藏原始服务器、应用防火墙、 TLS(或 SSL)终端的存在和特征,以及卸载、负载平衡、缓存静态内容和通过 WebSocket 启用动态内容。当必须从单个公共 IP 地址和端口访问多个 web 服务器时,也可以使用反向代理。

图 8-1 显示了一个简单的拓扑结构,在一个 HTTP 服务器和一个 WebSocket 服务器前面有一个反向代理服务器。

9781430247401_Fig08-01.jpg

图 8-1 。HTTP 和 WebSocket 服务器前的反向代理服务器

使用反向代理服务器有几个好处,包括使您能够独立于网络中的其他服务器部署、管理和更新 WebSocket 服务器。虽然反向代理服务器可以使您的应用使用单个端口来访问拓扑中的服务器,但是随着用户数量的增加,您需要考虑负载平衡选项。例如,您可能有一个为静态内容提供服务的 HTTP 服务器和多个为 web 应用提供动态内容的 WebSocket 服务器。

您可以使用一个反向代理作为您的服务器前面的负载平衡器,通过配置代理服务器来平衡许多 WebSocket 服务器之间的负载。比如你可以通过使用反向代理服务器指向ws1.example.com,ws2.example.com,等方式创建一个 HTTP 和 WebSocket 服务器的网络,如图图 8-2 所示。

9781430247401_Fig08-02.jpg

图 8-2 。反向代理服务器作为负载平衡器

反向连接

反向代理服务器连接到安全网络内部的服务器,以便建立端到端通信。有时,您希望使用的后端服务器无法接收连接,这通常发生在两种情况下:在早期开发期间,您希望允许连接到在本地主机或本地网络上运行的 WebSocket 服务器,以及在一些企业部署中,应用服务器位于阻止所有传入连接的防火墙之后(通常是出于安全原因和/或您组织的策略)。

You may recall our discussion about addressability in Chapter 3, where we identified a fundamental problem: some machines on the Internet can only make outgoing connections and cannot be directly addressed. WebSocket essentially solves this problem for web clients, which cannot be directly accessed by servers. Servers can only send newly available data to a client on a connection that was initiated by that client. By keeping a persistent connection open from web clients, WebSocket removes this limitation. Similarly, reverse connectivity or tunneling keeps a persistent connection open from WebSocket servers. Reverse connectivity for servers uses persistent connections from non-addressable hosts to publicly available endpoints. The publicly available endpoint forwards connections over this persistent tunnel to servers that would otherwise be unable to accept connections. If your WebSocket server does not have a public address, you may want to use reverse connectivity to make it available.

穿越代理 和带传输层安全的防火墙(TLS 或 SSL )

在本书中,我们经常提到传输层安全性(TLS,以前称为 SSL ),这是因为一个重要的原因。TLS 隐藏网络流量,使其免受中间人攻击者的检查和干扰。TLS 也有助于连接顺利地通过一些常见的 web 代理。在本节中,我们将研究不同类型的代理对 WebSocket 连接的影响。希望在本节结束时,您会明白为什么我们建议在 TLS 上部署 WebSocket,即使这不是一个安全需求。

因为转发代理管理专用网络和 Internet 之间的流量,所以如果连接打开的时间过长,它们也可以关闭连接。代理服务器的这种预期行为对需要持久连接的技术(如 WebSocket)来说是一种风险。我们将在本章的后面讨论如何用 pings 和 pongs 来抵消这种影响。代理也更有可能缓冲未加密的 HTTP 响应,从而在 HTTP 响应流期间引入不可预测的延迟。

不需要任何中间服务器,只要双方都理解 WebSocket 协议,就可以顺利建立 WebSocket 连接。然而,随着您和互联网之间网络中介的激增,在部署基于 WebSocket 的应用时,有些情况您需要了解,如表 8-1 中所述。

表 8-1 。使用加密/未加密的 WebSocket 和显式透明的代理服务器

Table8.1.jpg

代理通常分为两类:显式代理和透明代理。当浏览器被显式配置为使用代理服务器时,代理服务器是显式的。对于显式代理,您必须为您的浏览器提供代理的主机名、端口号以及可选的用户名和密码。当浏览器不知道流量被代理拦截时,代理服务器是透明的。

使用 WebSocket Secure (WSS) 增加了连接成功的几率,即使网络上有中间盒对传出连接进行透明检查和修改。

图 8-3 进一步说明了 WebSocket 连接成功或失败的情况,这取决于变量的组合,例如普通 WebSocket 协议与 WebSocket 安全和显式代理配置以及透明代理服务器。

9781430247401_Fig08-03.jpg

图 8-3 。WebSocket 如何与代理服务器交互

正如你在图 8-3 中所看到的,使用 WSS 可以极大地增加你的 WebSocket 连接成功的机会,即使流量必须通过显式和透明的代理。

部署 TLS

部署 TLS 需要用于标识 WebSocket 服务器的加密数字证书。在生产环境中,这些证书必须由 web 浏览器已知并信任的证书颁发机构(CA)签名。如果您使用不受信任的证书,用户在访问您的服务器时会看到安全错误,这就是 TLS 防止中间人攻击在连接打开时劫持连接的方式。在开发过程中,您可以签署自己的证书,并将浏览器配置为信任这些证书并忽略安全警告。

WebSocket Pings 和 pong

连接可能会因为许多您无法控制的原因而意外关闭。任何 web 应用都应该被编码成能够优雅地处理间歇性连接并适当地恢复。然而,有一些原因可以而且应该避免连接关闭。可以避免的连接丢失的一个常见原因是 TCP 层的不活动,这反过来会影响 WebSocket 连接。

image 注意因为 WebSocket 连接位于 TCP 连接之上,所以发生在 TCP 层的连接问题会影响 WebSocket 连接。

在您的客户端和 WebSocket 服务器之间使用全双工连接时,可能会出现连接上没有数据流的情况。此时,网络中介可以终止连接。具体来说,不知道“始终在线”连接的网络组件有时会关闭不活动的 TCP 连接,从而关闭 WebSocket 连接。例如,代理服务器和家庭路由器有时会终止它们认为是空闲的连接。WebSocket 协议支持 pings 和 pongs 来对连接执行健康检查并保持连接打开。

使用 WebSocket pings 和 pongs 保持连接打开,并为数据流做好准备。Pings 和 pongs 可以来自开放 WebSocket 连接的任何一端。WebSocket 协议支持客户端发起的和服务器发起的 pings 和 pongs。浏览器或服务器可以在适当的时间间隔发送 pings 和/或 pongs,以保持连接活动。注意,我们说的是浏览器,而不是 WebSocket 客户端:正如我们在第二章中提到的,WebSocket API 目前不支持客户端发起的 pings 和 pongs。虽然浏览器可以根据自己的保持活动和健康检查策略发送 ping 和 pong,但大多数 ping 和 pong 都是由服务器发起的;然后,WebSocket 客户端可以用 pong 来响应 ping。或者,浏览器或服务器可以发送 pong 而不接收 ping,这使您可以灵活地保持连接。您使用的确切间隔取决于应用的受众以及应用的 WebSocket 连接上的正常数据流量。保守地说,每 30 秒发送一次 pong 应该可以保持大多数连接的活动,但是不频繁地发送 pong 可以节省带宽和服务器资源。

WebSocket 缓冲和节流

对于使用全双工连接的 WebSocket 应用,您可以控制应用向服务器发送数据的速率,也称为“节流”限制流量有助于避免网络中的饱和或瓶颈,这可能会受到其他限制的影响,如互联网带宽和服务器 CPU 限制,我们将在后续部分中讨论。WebSocket API 使你能够控制应用向服务器发送数据的速率,使用 WebSocket bufferedAmount属性,我们在第二章中讨论过。bufferedAmount属性表示已经排队但尚未传输到服务器的字节数。

您还可以限制客户端到服务器的连接,并允许服务器根据服务器中预定义的设置来决定是接受还是拒绝客户端连接。

监控

要评估系统性能,您还可以配置一个监视工具来跟踪用户活动、服务器性能,并在必要时终止客户端会话。监控非常有用,不仅可以分析网络和系统的健康状况,还可以诊断性能瓶颈或故障的根本原因,并确定可以在哪些方面优化系统以获得更好的性能。

理想情况下,您应该能够提供所需的可见性和控制,以确保业务事务顺利地通过系统,并满足服务级别协议(SLA)。

产能规划

在您的架构中实现 WebSocket 使您能够构建灵活且可伸缩的框架。即使有了这种灵活性,您仍然必须规划部署需求,包括规模考虑,特别是与硬件容量相关的考虑。这些领域包括服务器的内存和 CPU(无论是支持 WebSocket 的后端服务器,还是支持 WebSocket 流量在客户端和后端服务器之间流动的网关),以及网络优化。一般来说,调整意味着估计您的应用的硬件需求。

表 8-2 描述了在考虑 WebSocket 应用的硬件需求时你可能需要考虑的方面。此清单中的项目包含部署任何应用时要考虑的因素,并且随着用户群、数据和系统的增长,这些因素肯定会随着时间的推移而变化。

表 8-2 。产能规划清单

image

或者,有许多基于云的 WebSocket 服务产品,它们基本上消除了 WebSocket 开发人员考虑容量规划的需要。服务提供商负责确保为他们的客户提供足够的容量,允许部署的应用进行弹性扩展。

插座限制

如您所知,WebSocket 服务器同时保持许多连接打开。您应该意识到,如果您在不改变任何操作系统设置的情况下运行服务器,您可能无法维持几千个以上的开放套接字。您将看到错误报告,指出您无法打开更多文件,即使有足够的 CPU 和内存资源可用。(记住,在 UNIX 中,几乎所有东西都是文件,包括套接字!即使您没有使用磁盘,也可能会看到有关文件的错误信息。)操作系统限制每个用户打开文件的数量;默认情况下,这个限制相当低。这些限制是为了防止共享系统上的滥用,在共享系统中,许多用户不得不争夺相同的资源。但是,在服务器上,您可能希望允许一个进程全速运行,并尽可能多地使用打开的文件。

例如,在 Linux 上,命令ulimit -a显示当前的用户限制,包括打开文件的最大允许数量。幸运的是,您可以在 Linux 上提高这个限制(例如,您可以运行ulimit -n 10000将用户限制设置为一万个打开的文件)。还有一个系统范围的最大值fs.file-max,您可以使用sysctl命令提高它。这些命令可能因您的操作系统而异,因为文件限制取决于操作系统。例如,在 Microsoft Windows 上,命令因版本而异;在某些情况下,您不能修改限制。请查阅您的系统的参考资料,以便为您的 WebSocket 服务器设置打开的套接字的最大数量。

WebSocket 应用部署清单

表 8-3 总结了部署 WebSocket 应用的注意事项。

表 8-3 。WebSocket 应用部署清单

image image

摘要

在本章中,我们研究了在构建了 WebSocket 服务器和应用,并添加了必要的安全增强功能以准备供公众使用的应用之后可以采取的步骤。我们研究了应用开发人员可以执行的一些任务,以允许所有类型的用户访问他们的 WebSocket 应用,即使用户没有支持 WebSocket 的浏览器。我们探讨了如何使用反向代理服务器和 TCP keepalive 来维护 WebSocket 连接,使用 TLS 来保护数据(不仅防止入侵者,还防止代理和防火墙),以及规划企业范围的部署。

现在,您已经和我们一起回顾了 WebSocket API 和协议的历史,并研究了使用 WebSocket 优于旧 HTTP 架构的优势。我们已经查看了样本 WebSocket 流量,并通过这次检查见证了性能改进的可能性。我们已经逐步使用 WebSocket API,并看到在客户机和服务器之间创建全双工双向通信比使用更老的(更复杂的)基于 AJAX 的架构要简单得多。我们探索了一些可以使用 WebSocket 通过广泛使用的标准(如 XMPP 和 STOMP)在 Web 上扩展 TCP 层应用协议的强大方法。通过这些用例,我们了解了如何为标准聊天和消息协议提供全双工实时功能。我们已经看到了如何使用 WebSocket 上的 VNC 到无插件的 HTML5 浏览器客户端轻松实现桌面共享。我们还研究了 WebSocket 应用的安全性和部署,以及在公开应用之前应该考虑的事项。

在阅读了 HTML5 WebSocket 的权威指南之后,我们希望您不仅对 WebSocket 有了很好的理解,而且能够利用这一技术来提升您现有的应用和架构,并开发以前难以实现的新应用。它仍处于早期阶段,我们相信 webSocket 不仅会改变 Web 开发,还会改变用户通过 Web 与信息和设备交互的方式。

九、附录 A:检查 WebSocket 流量

当使用 WebSockets 试验和构建应用时,有时您可能需要仔细看看幕后到底发生了什么。在本书中,我们使用了一些工具来检查 WebSocket 流量。在本附录中,我们将回顾三个方便的工具:

  • Google Chrome 开发者工具:Chrome 附带的一组 HTML5 应用,允许您检查、调试和优化 Web 应用
  • Google Chrome Network Internals(或“net-internals”):一组工具,允许您检查网络行为,包括 DNS 查找、SPDY、HTTP 缓存以及 WebSocket
  • Wireshark:使您能够分析网络协议流量的工具

使用谷歌 Chrome 开发工具进行 WebSocket 框架检查

谷歌 Chrome 开发者工具提供了广泛的功能来帮助网络开发者。在这里,我们重点关注它如何帮助您了解和调试 WebSockets。如果你有兴趣了解更多关于谷歌 Chrome 开发者工具的知识,网上有很多信息。

要访问开发者工具,请打开 Google Chrome,然后单击地址栏右侧的自定义和控制 Google Chrome 图标。选择工具image开发者工具,如图 A-1 所示。大多数使用该工具的开发人员通常更喜欢键盘快捷键,而不是菜单选项。

9781430247401_AppA-01.jpg

图 A-1 。打开谷歌浏览器开发者工具

Google Chrome 开发者工具通过八个面板为您提供关于您的页面或应用的详细信息,允许您执行以下任务:

  • 元素面板 : 检查并修改 DOM 树
  • 资源面板 : 检查加载的资源
  • 网络面板 : 检查网络通信;这是您在构建支持 WebSocket 的应用时最常用的面板。
  • 源代码面板 : 检查源文件并调试 JavaScript
  • 时间轴面板 : 分析加载页面或与页面交互时花费的时间
  • 配置面板 : 配置时间和内存使用情况
  • 审计小组 : 在页面加载时对其进行分析,并提出改进建议。
  • 控制台:显示错误信息,执行命令。控制台可以与上述任何面板一起使用。按键盘上的 Esc 键打开和关闭控制台。除了网络面板,控制台是 Web 和 WebSocket 开发者最好的朋友。

首先,让我们仔细看看网络面板。打开 Chrome 并导航至http://www.websocket.org。我们将使用websocket.org上的 Echo 测试来了解 Google Chrome 开发者工具提供的 WebSocket 框架检查。要访问 Echo 演示,请单击页面上的 Echo 测试链接,这将带您进入http://www.websocket.org/echo.html。如果尚未打开 Google Chrome 开发者工具,请打开它,然后单击网络面板。确保您的网络面板是空的。如果不是空的,点击 Chrome 窗口底部的 Clean 图标,图 A-2 中左起第六个图标。

9781430247401_AppA-02.jpg

图 A-2 。使用 Google Chrome 开发工具检查 WebSocket 连接的创建

注意,location 字段包含一个我们将连接到的 web socket URL:ws://echo.websocket.org。单击“连接”按钮创建连接。请注意,WebSocket 连接显示在您的网络面板中。点击标题选项卡下的名称echo.websocket.org;这样做可以让你看到 WebSocket 握手(图 A-3 )。列表 A-1 展示了整个 WebSocket 握手过程。

9781430247401_AppA-03.jpg

图 A-3 。检查 WebSocket 握手

列举 A-1。web socket 握手

Request URL:ws://echo.websocket.org/?encoding=text
Request Method:GET
Status Code:101 Web Socket Protocol Handshake
Request Headers
Connection:Upgrade
Cookie:__utma=9925811.531111867.1341699920.1353720500.1353725565.33;
__utmb=9925811.4.10.1353725565; __utmc=9925811; __utmz=9925811.1353725565.33.30.
utmcsr=websocket.org|utmccn=(referral)|utmcmd=referral|utmcct=/
Host:echo.websocket.org
Origin:http://www.websocket.org
Sec-WebSocket-Extensions:x-webkit-deflate-frame
Sec-WebSocket-Key:JfyxfhR8QIm3BSb0q/Tw5w==
Sec-WebSocket-Version:13
Upgrade:websocket
(Key3):00:00:00:00:00:00:00:00
Query String Parameters
encoding:text
Response Headers
Access-Control-Allow-Credentials:true
Access-Control-Allow-Headers:content-type
Access-Control-Allow-Origin:http://www.websocket.org
Connection:Upgrade
Date:Sat, 24 Nov 2012 03:08:27 GMT
Sec-WebSocket-Accept:Yr3WGnQMtPOktDVP1aBU3l5DfFI=
Server:Kaazing Gateway
Upgrade:WebSocket
(Challenge Response):00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00

现在,您可以随意更改消息字段的内容,并单击 Send 按钮。要检查 WebSocket 框架,你需要再次点击最左边的名称,这将刷新右边的面板,增加框架选项卡,如图 A-4 所示。

9781430247401_AppA-04.jpg

图 A-4 。检查 WebSocket 框架

WebSocket Frame inspector 显示数据(本例中为文本)、数据长度、发送时间以及数据方向:浅绿色背景表示从浏览器到 WebSocket 服务器的流量(上传),白色表示从服务器到浏览器的流量(下载)。

image 注意当你发送 WebSocket 消息时,确保总是点击 Name 列来触发 Frames 标签的刷新。

当您导航到 Sources 选项卡并定位到echo.js文件时,您会看到一个名为"websocket"的变量,它表示我们的 WebSocket 连接。通过显示控制台,您可以使用send()函数简单地向 WebSocket 服务器发送消息,如清单 A-2 中的所示。

清单 A-2。 使用 Chrome 控制台发送 WebSocket 消息

websocket.send("Hello World!");

在图 A-5 中,我们从控制台发送了一个Hello World!消息,您可以在日志窗口中看到,Echo 服务向我们发送了一个响应。如果您显示您的网络选项卡,您还可以看到相应的 WebSocket 框架。

9781430247401_AppA-05.jpg

图 A-5 。从 Chrome 控制台发送 WebSocket 消息

如前所述,Chrome 开发者工具为 web 开发者提供了一种简单有效的方法来“查看”他们的应用。Chrome 的网络选项卡不仅提供了对 WebSocket 握手的独特见解,还允许您轻松检查 WebSocket 帧。

谷歌 Chrome 网络内部

大多数时候,Chrome 开发者工具显示的信息足以高效地开发和调试 web 应用。但是,有时较低级别的详细信息可以帮助诊断异常的连接故障,或者在调查浏览器本身的行为时提供无法访问的信息。Chrome 有内部诊断页面,这在你想观察浏览器内部状态的罕见情况下非常有用。Chrome 的内部工具公开了与 DNS 请求、SPDY 会话、TCP 超时、代理和浏览器的其他内部工作相关的事件。

谷歌 Chrome 包括几个额外的工具。要查看它们的列表,请在浏览器的地址栏中键入chrome://about

image 注意在谷歌 Chrome 中,网址about:about重定向到chrome://about。其他浏览器,如 Mozilla Firefox,在它们的about:about页面上列出了有用的网址。

该页面显示以下有用的内部 Chrome 工具列表:

  • chrome://appcache-internals
  • chrome://blob-internals
  • chrome://bookmarks
  • chrome://cache
  • chrome://chrome-urls
  • chrome://crashes
  • chrome://credits
  • chrome://dns
  • chrome://downloads
  • chrome://extensions
  • chrome://flags
  • chrome://flash
  • chrome://gpu-internals
  • chrome://history
  • chrome://ipc
  • chrome://inspect
  • chrome://media-internals
  • chrome://memory
  • chrome://nacl
  • chrome://net-internals
  • chrome://view-http-cache
  • chrome://newtab
  • chrome://omnibox
  • chrome://plugins
  • chrome://policy
  • chrome://predictors
  • chrome://profiler
  • chrome://quota-internals
  • chrome://settings
  • chrome://stats
  • chrome://sync-internals
  • chrome://terms
  • chrome://tracing
  • chrome://version
  • chrome://print

在地址栏中,键入chrome://net-internals。net-internals 的一个用途是检查 TCP 套接字事件。这些 TCP 套接字用于传输 WebSocket 和浏览器用于通信的其他协议。当你点击左边的 Sockets 时,Chrome 会显示套接字池。我们感兴趣的是当前活动的实时套接字,因此单击查看实时套接字链接。在一个单独的窗口或标签中,在http://www.websocket.org/echo.html打开 WebSocket Echo 测试,并点击 Connect。一个新的条目马上出现,伴随着下面的 URL: ws://echo.websocket.org/?encoding=text。点击条目,在右边你会看到网络内部,如列表 A-4 所示。

列举 A-4。 网络内部握手

830: SOCKET
ws://echo.websocket.org/?encoding=text
Start Time: 2012-11-23 20:08:27.489

t=1353730107489 [st=  0] +SOCKET_ALIVE  [dt=?]
                          --> source_dependency = 828 (SOCKET_STREAM)
t=1353730107489 [st=  0]   +TCP_CONNECT  [dt=91]
                            --> address_list = ["174.129.224.73:80"]
t=1353730107489 [st=  0]      TCP_CONNECT_ATTEMPT  [dt=91]
                              --> address = "174.129.224.73:80"
t=1353730107580 [st= 91]   -TCP_CONNECT
                            --> source_address = "10.0.1.5:57878"
t=1353730107582 [st= 93]    SOCKET_BYTES_SENT
                            --> byte_count = 470
t=1353730107677 [st=188]    SOCKET_BYTES_RECEIVED
                            --> byte_count = 542

现在,让我们从显示websocket.org的窗口发送一条消息。net-internals 面板刷新,显示发送的字节数(参见图 A-6 )。

9781430247401_AppA-06.jpg

图 A-6 。Google Chrome net-内部实用程序

与 Google 开发者工具非常相似,net-internals 是与 Google Chrome 一起打包和发货的。如果需要更深入、更低级别的网络诊断,Net-internals 是一个非常方便的工具。

使用 Wireshark 分析网络数据包

Wireshark 是一个非常强大的免费开源工具(可在http://www.wireshark.org下载),它提供了对网络接口的详细了解,允许您查看和分析网络上传输的内容。Wireshark 是 WebSocket 开发人员手中的有用工具,但也被网络管理员广泛使用。Wireshark 可以通过网络接口捕获实时网络数据,然后您可以对这些数据进行导出/导入、过滤、颜色编码和搜索。

图 A-7 显示了 Wireshark 捕获网络数据包时的用户界面。在菜单栏和主工具栏下,您可以看到过滤器工具栏,它用于过滤收集的数据。该数据以表格形式显示在数据包列表窗格中。数据包详细信息窗格显示数据包列表窗格中所选数据包的信息。状态栏正上方的数据包字节窗格显示在数据包列表窗格中选择的数据包数据。

9781430247401_AppA-07.jpg

图 A-7 。Wireshark 捕获网络数据包

启动 Wireshark 并选择您正在使用的网络适配器:如果您硬连线到网络,您的适配器将与您使用 WiFi 时不同。在我们的 Wireshark 实验中,我们将检查运行在websocket.org上的浏览器和 WebSocket 服务器之间的 WebSocket 流量。首先,使用浏览器导航至http://www.websocket.org。然后,点击回声测试链接。你也可以将浏览器直接指向http://www.websocket.org/echo。现在,您已经准备好建立 WebSocket 连接了。单击连接按钮。

由于网络上的流量很大,你的浏览器和 websocket.org 之间的流量很快就会消失。为了确保我们看到一些有用的数据,我们将过滤去往www.websocket.org的流量。

图 A-8 展示了如何过滤掉带有特定 IP 地址的数据包:ip.dst_host==174.129.224.73。Wireshark 支持条件中的双等号,以及eq运算符。在此图中,还要注意数据包详细信息页面中的 WebSocket 握手。

9781430247401_AppA-08.jpg

图 A-8 。过滤网络数据包网络数据包

Wireshark 的另一个重要特性是它可以遵循各种协议流。在图 A-9 中,你可以看到它是如何跟随 TCP 流的。它显示与所选数据包在同一 TCP 连接上的 TCP 段。您可以通过在数据包列表窗格中右键单击数据包并从上下文菜单中选择 follow 来跟踪协议流。

9781430247401_AppA-09.jpg

图 A-9 。跟随 TCP 流

要查看 Wireshark 如何实时更新数据包列表,请在浏览器中提交一条 WebSocket 消息。图 A-10 显示了如何将文本通过 WebSocket 提交给 Wireshark 中的 Echo 服务。

9781430247401_AppA-10.jpg

图 A-10 。Wireshark 更新直播

摘要

在本附录中,我们解释了一些用于检查、分析和调试 WebSocket 流量的有用工具。这些工具将帮助您构建支持 WebSocket 的应用。下一个附录讨论了我们提供的虚拟机(VM ),其中包括我们用来构建本书中的示例的开放源代码(库、工具和服务器)。

十、附录 B:WebSocket 资源

在本书中,我们使用了大量的资源来帮助我们每天构建 WebSocket 应用。在本附录中,我们将介绍如何使用 VM(虚拟机),其中包含您构建或遵循本书中的示例所需的所有预安装代码和软件。我们还总结了从哪里获得本书中使用的所有库、服务器和其他技术。最后,我们列出了在写这本书时可用的 WebSocket 服务器和客户机。

使用虚拟机

这本书附带的 VM 可以从出版商的网站上下载。只需导航到http://apress.com并搜索这本书的书名(或直接进入www.apress.com/9781430247401)。单击源代码/下载选项卡,然后单击立即下载。下载后,您可以使用 VirtualBox 启动虚拟机。VirtualBox 可以从http://virtualbox.org免费下载,适用于 Windows、Mac、Linux 和 Solaris 主机操作系统。

打开虚拟机,解压后双击WebSocketBook.ova文件,或者从 VirtualBox 菜单中选择文件image导入设备,选择WebSocketBook.vbox文件。虚拟机的操作系统是 Ubuntu。

下载并安装虚拟机后,您会注意到桌面上有几个项目:

  • 第二章–第六章的图标
  • 一个README.txt文件

首先,打开并阅读README.txt,它解释了安装 VM 时自动为您启动的服务器和服务。要构建第二章–第六章中描述的示例,您可以简单地根据 VM 中提供的服务器和库开始构建,这将在相关章节中描述。

表 B-1 和 B-2 描述了我们在整本书中使用的服务器和库,以及它们是否包含在 VM 中。

表 B-1。本指南中使用的服务器

image

表 B-2。本指南中使用的库和其他工具

image

WebSocket 服务器

虽然您可以让服务器接受 WebSocket 连接或者编写自己的 WebSocket 服务器,但是有一些现有的实现可能会让您在开发自己的 WebSocket 应用时更加轻松。在撰写本书时,以下是一些可用的 WebSocket 服务器(列表由http://refcardz.dzone.com/refcardz/html5-websocket提供):

  • 炼金术-Websockets(。NET): http://alchemywebsockets.net/
  • Apache ActiveMQ:??]
  • apache-websocket (Apache 模块):http://github.com/disconnect/apache-websocket#readme
  • APE 项目(C): http://www.ape-project.org/
  • Autobahn(虚拟设备):http://autobahn.ws/
  • 橡胶树脂(Java): http://www.caucho.com/
  • 牛仔:http://github.com/extend/cowboy
  • 抽筋(红宝石):http://cramp.in/
  • 扩散(商业产品):http://www.pushtechnology.com/home
  • EM-WebSocket (Ruby): http://github.com/igrigorik/em-websocket
  • 可扩展 WebSocket 服务器(PHP): http://github.com/wkjagt/Extendible-Web-Socket-Server
  • gevent-websxmlsocket(python):http://www.gelens.org/code/gevent-websocket/
  • GlassFish (Java): http://glassfish.java.net/
  • 歌利亚(红宝石):http://github.com/postrank-labs/goliath
  • Jetty (Java): http://jetty.codehaus.org/jetty/
  • jWebsocket (Java): http://jwebsocket.org/
  • Kaazing WebSocket 网关(商业产品):http://kaazing.com/
  • libwebsockets (C): http://git.warmcat.com/cgi-bin/cgit/libwebsockets/
  • 弥赛亚(杨二郎):http://github.com/ostinelli/misultin
  • net.websocket (Go): http://code.google.com/p/go.net/websocket
  • Netty (Java): http://netty.io/
  • 掘金(。NET): http://nugget.codeplex.com/
  • phpdaemon (PHP): http://phpdaemon.net/
  • 推送器(云服务):http://pusher.com/
  • pywebsockets (Python): http://code.google.com/p/pywebsocket/
  • rabbitmq(杨二郎):http://github.com/videlalvaro/rabbitmq-websockets
  • Socket.io (Node.js ): http://socket.io/
  • SockJS-node(节点):http://github.com/sockjs/sockjs-node
  • SuperWebSocket (.NET): http://superwebsocket.codeplex.com/
  • Tomcat (Java): http://tomcat.apache.org/
  • 龙卷风(巨蟒):http://www.tornadoweb.org/
  • txweb socket(Python/Twisted):http://github.com/rlotun/txWebSocket
  • 绿. x (Java): http://vertx.io/
  • Watersprout (PHP): http://github.com/chrisnetonline/WaterSpout-Server/blob/master/server.php
  • web-socket-ruby (Ruby): http://github.com/gimite/web-socket-ruby
  • Webbit (Java): http://github.com/webbit/webbit
  • WebSocket-Node (Node.js): http://github.com/Worlize/WebSocket-Node
  • websockify (Python): http://github.com/kanaka/websockify
  • XSockets(。NET): http://xsockets.net/
  • 雅司病(二郎)http://yaws.hyber.org/websockets.yaws
posted @ 2024-08-13 14:31  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报