第六章 WebSockets API
本章我们将研究HTML5 WebSockets的使用方法。HTML5 WebSockets是HTML5中最强大的通信功能,它定义了一个全双工通信信道,仅通过Web上的一个Socket即可进行通信。WebSockets不仅仅是对常规HTTP通信的另一种增量加强,它更代表着一次巨大的进步,对实时的,事件驱动的Web应用程序而言更是如此。
过去要在浏览器中实现全双工连接,必须通过迁回的”hacks” 来模拟实现,相比之下HTML5 WebSockets带来的改进是如此之大,以至于HTML5规范的领军人物一Google 工程师IanHickson 都说:
“数据从几千字节减少到了两字节,延迟从150ms减少到了50ms一一这不可小看.实际上,仅仅这两个因素已经足以引起Google对WebSockets 的兴趣了。”
——www.ietf.org/mail-archive/web/hybi/current/msg00784.html
HTML5 WebSockcts为何能够带来如此显著的改善?接下来我们会详细讨论,同时还会看到HTML5 WebSockcts是如何一举淘汰传统的Comet和Ajax 轮询(polling) 、长轮询(long-polling)以及流(streaming) 解决方案的。
6.1 HTML5 WebSockets 概述
让我们与HTTP解决方案比一比,看看在全双工实时浏览器通信中。 HTML5 WebSockets 是如何喊少不必要的网络流量并降低网络延迟的。
6.1.1 实时和HTTP
正常情况下,浏览器坊问Web 页面时,一般会向页面所在的Web 服务器发送一个HTTP请求。Web服务器识别请求,然后返回响应。大多数情况下,如股票价格、新闻报道、余票查询、交通状况、医疗设备读取数据等,当内容呈现在浏览器页面上时,可能已经没有时效性。如果用户想要摸得最新的实时信息,就需要不断地手动刷新页面,这显然不是一个明智的做法。
目前实时Web应用的实现方式,大部分是围绕轮询和其他服务器端推送技术展开的,其中最著名的是Comet. Comet技术可以让服务器端主动以异步方式向客户端推送数据,它会使针对传输消息到客户端的响应延迟完成。
使用轮询时,浏览器会定期发送HTTP请求,并随即接收响应。这项技术是浏览器在实时信息传送方面的首次尝试。显然,如果知道消息传递的准确时间间隔,轮询将是一个很好的办法。因为可以将客户端的请求同步为只有服务器上的信息,可用时才发出。但是,实时数据往往不可预测,不可避免会产生一些不必要的请求,在低消息率情况下会有很多无用的连接不断地打开和关闭。
使用最轮询时,浏览器向服务器发送一个请求,服务器会在一段时间内将其保持在打开状态。如果服务器在此期间收到一个通知,就会向客户端发送一个包含消息的响应。如果时间已到却还没收到通知,服务器会发送一个响应消息来终止打开的请求,然而,最关键的是,当信息量很大时,与传统轮询方式相比,长轮询方式并无实质上的性能改善。
使用流捷解决方案时,浏览器会发送一个完整的HTTP请求,但服务器会发送并保持一个处于打开状态的响应,该响应持续更新并无限期(或是一段时间内)处于打开状态。每当有消息可发送时该响应就会被更新,但服务器永远不会发出响应完成的信号,这样连接就会一直保持在打开状态以便后续消息的发送。但是,由于流仍是封装在HTTP中,其间的防火墙和代理服务器可能会对响应消息进行缓冲,造成消息传递的时延。因此,当检测到缓冲代理服务器时,许多流解决方案就回退到长轮询方式。此外,可利用TLS(SSL) 连接来保护响应不被缓冲,但在这种情况下,每个链接的创建和清除会消耗更多的服务器资源。
综上所述,所有这些提供实时数据的方式都会涉及HTTP请求和响应报头,其中包含有大量额外的、不必要的报头数据,会造成传输延迟。最重要的是,全双工连接需要的不仅仅是服务器到客户端的下行连接,为了在半双工HTTP的基础上模拟全双工通倍,目前的许多解决方案都使用了两个连接: 一个用于下行数据流,另一个用于上行数据流,这两个连接的保持和协作也会造成大量的资源消耗,并增加了复杂度。简而言之,HTTP技术并不是为了实现实时全双工通信设计的,从图6-1也可以看出来,基于半双工HTTP。构建一个采用发布/订阅模式来利用后端数据源显示实时数据的Web 应用程序是比较复杂的。
图6-1 实时HTTP应用程序的复杂性
当开发人员试图对上述方案继续扩展肘,情况会变得更糟。模拟基于HTTP的双向浏览器通信是非常复杂和易错的,而且复杂度不可控。虽然最终用户感觉Web应用像是实时的, 但是这种”实时”体验的代价非常高,包括额外的时间延迟、不必要的网络流量和CPU性能消耗。
当开发人员试图对上述方案继续扩展肘,情况会变得更糟。模拟基于HTTP的双向浏览器通信是非常复杂和易错的,而且复杂度不可控。虽然最终用户感觉Web应用像是实时的, 但是这种”实时”体验的代价非常高,包括额外的时间延迟、不必要的网络流量和CPU性能消耗。
6.1.2 解读HTML5 WebSockets
一开始HTML5规范的首席作者lan Hickson将WebSockets在规范的Communications章节中定义成了TCPConncction,随着规范的演进,后来才改名为WebSockets。现在,就像Geolocation、Web Workers 一样,为了明确主题,WebSockets 已成为一个强立的规范。
WebSockets与火车模型有何联系?
“Ian Hickson是火车模型的爱好者,他一直在寻找一种通过计算机控制火车模型的方法,这种想法自1984年Marklin首次推出了数字控制器就开始了,比Web的出现时间还要乎很多。
在lan将TCPConnection 引入到HTML5规范时,他正致力于通过浏览器来控制火车模型,通过经典的pre-WebSocket “hangjng GET”和XHR技术来实现浏览器和火车之间的通信。如果当时浏览器中有socket通信可以实现.(成像”胖”客户端上那样的异步客户端/服务器通信模式)的话,火车控制器程序就会简单得多。因此,来自一切皆有可能的灵感, (火车)车轮已经开动,而且WebSocket火车已经离站。下一站:实时Web。”
一一Peter
1、WebSocket握手
为了建立WebSocket通信,客户端和服务器在初始握手时,将HTTP协议升级到了WebSocket协议,如代码清单6- 1所示。请注意,该连接是在草案76 (Draft76)中描述的,它在后续规范修订中还可能会发生变化。
代码清单6-1 WebSocket升级握手
从客户端到服务器:
From client to server: GET /demo HTTP/1.1 Host: example.com Connection: Upgrade Sec-WebSocket-Key2: 12998 5 Y3 1 .P00 Sec-WebSocket-Protocol: sample Upgrade: WebSocket Sec-WebSocket-Key1: 4@1 46546xW%0l 1 5 Origin: http://example.com [8-byte security key]
从服务器到客户端:
HTTP/1.1 101 WebSocket Protocol Handshake Upgrade: WebSocket Connection: Upgrade WebSocket-Origin: http://example.com WebSocket-Location: ws://example.com/demo WebSocket-Protocol: sample [16-byte hash response]
一旦连接建立成功,就可以在全双工模式下在客户端和服务器之间来回传送WebSocket 消息。这就意味着,在同一时间、任何方向,都可以全双工发送基于文本的消息。在网络中,每个消息以0x00字节开头,以0xFF结尾。中间数据来用UTF-8编码格式。
2、WebSocket接口
除了对WebSocket 协议的定义外,模规范还同时定义了用于JavaScript应用程序的WebSocket接口,如代码清单6-2所示。
代码清单6-2 WebSocket接口
[Constructor(in DOMString url, in optional DOMString protocol)] interface WebSocket { readonly attribute DOMString URL; // ready state const unsigned short CONNECTING = 0; const unsigned short OPEN = 1; const unsigned short CLOSED = 2; readonly attribute unsigned short readyState; readonly attribute unsigned long bufferedAmount; // networking attribute Function onopen; attribute Function onmessage; attribute Function onclose; boolean send(in DOMString data); void close(); }; WebSocket implements EventTarget;
WebSocket接口的使用很简单,要连接远程主机,只需要新建一个WebSocket 实例,提供希望连接的对端URL。注意,ws://和wss://前缀分别表示WebSocket连接和安全的WebSocket连接。
基于同一底层TCP/IP连接,在客户端和服务器之间的初始握手阶段,将HTTP协议升级至WebSocket协议,WebSocket连接就建立完成了,连接一旦建立,WebSocket 数据帧就可以以全双工模式在客户端和服务器之间进行双向传送。连接本身是通WebSocket接口定义的message事件和send函数来运作的。在代码中,采用异步事件帧听器来控制连接生命周期的每一个阶段。
myWebSocket.onopen = function(evt) { alert("Connection open ..."); }; myWebSocket.onmessage = function(evt) { alert( "Received Message: " + evt.data); }; myWebSocket.onclose = function(evt) { alert("Connection closed."); };
3、大幅消减不必要的网络流量和时延
那么, WebSocket 效率究竟有多高?我们通过逐项对比轮询应用程序和WebSocket 应用程序来说明。这里以一个简单Web应用程序为例来演示轮询方式,页面使用传统的发布/订阅模式从RabbitMQ消息代理(message broker)请求实时股票信息,通过轮询Web 服务器上的Java Servlet来运作. RabbitMQ 消息代理从一个虚拟的、股票价格持续更新的数据源接收数据。网页连接并注册一个特定的股票频道(消息代理中的一项) ,使用XMLHttpRequest进行轮询,更新频率为每秒一次。当收到更新时,经过一些计算,股票数据就会显示在表格中,如图6-2 所示。
图6-2 一个JavaScript股票行情应用程序
看起来似乎不错,但从另一个角度考察,就会发现这个应用程序其实存在很严重的问题。例如,在带有Firebug插件的Firefox浏览器中。你可以看到每秒都会有一个GET情求发往服务器。从下方的HTTP报头数据中可以发现,每个请求关联的报头开销相当惊人。代码清单6-3和代码清单6-4分别是某次请求和响应的KTTP报头数据。
代码清单6-3 HTTP请求报头
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://www.example.com/PollingStock/ Cookie: showInheritedConstant=false; showInheritedProtectedConstant=false; showInheritedProperty=false; showInheritedProtectedProperty=false; showInheritedMethod=false; showInheritedProtectedMethod=false; showInheritedEvent=false; showInheritedStyle=false; showInheritedEffect=false
代码清单6-4 HTTP响应报头
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: 21 Date: Sat, 07 Nov 2009 00:32:46 GMT
做一个有趣的统计(哈哈) ,我们来统计一下字符数。HTTP请求和响应报头信息总共包含有871B的额外开销,这些开销中不含任何数据。当然,这只是一个示例,实际中的报头数据可能少于871B,但超过2000B也很常见。在本示例应用程序中,典型的股票消息大约只占到20个字符。但从代码中可以看到,这部分信息已经被太多不必要的报头信息所淹没,而这些报头信息完全没必要在第一时间接收。
那么,把这种应用程序部署后供大量用户使用会有什么后果呢?让我们看看在三种不同应用场景下,这种轮询应用关联的HTTP请求和响应报头数据所需要的网络开销有多大。
- 场景A: 1000客户,每秒轮询一次,网络流量为871 x 1 000=871 000B=6 968 000bit/s(6.6Mbit/s)。
- 场景B: 10 000客户,每秒轮询一次,网络流量为871 x 10 000=8710 000B=69 680 000bit/s(66Mbi/s)。
- 场景C: 100 000客户,每秒轮询一次,网络流量为871 x 100 000=87100 000B=696 800 000bit/s(665Mbit/s)。
可见,不必要的网络开销相当庞大。现在我们换用自HTML5WebSockets来重构应用程序。在页面中添加一个事件处理程序,用于异步监听来自消息代理的股票更新消息(再加上一点点其他的改动)。每个消息都是一个WebSockets帧,只有2B的开销(而不是871B)。让我们再来看看三个应用场景中引入了WebSockets后网络开销的变化。
- 场景A: 1000客户,每秒轮询一次,网络流量为2×1 000=2 000B=16 000bit/s(0.015Mbit/s)。
- 场景B: 10 000客户,每秒轮询一次,网络流量为2×10 000=20 000B=160 000bit/s(0.153Mbit/s)。
- 场景C: 100 000客户,每秒轮询一次,网络流量为2×100 000=200 000B=1600 000bit/s(1.5263Mbit/s)。
如图6-3所示,与轮询方式相比,HTML5 WebSockets对不必要的网络流量的减少幅度相当惊人。
图6-3 轮询和WebSocket应用程序间不必要网络流量的对比
时延减少情况呢?图6-4中上半部分表示半双工轮询方案的时廷。举例来说,假设消息从服务器发送到浏览器需要50ms。轮询应用程序会引人许多额外的时廷,因为只能在响应完成时将新的请求发送到服务器。这个新请求又需要50ms,在此期间服务器无法向浏览器发送任何消息,进而造成额外的服务器内存消耗。
图6-4 轮询和WebSocket应用程序间时延的对比
从图6-4中下半部分可以看出WebSocket方案降低时延的情况。一旦连接升级为WebSocket ,消息会在到边服务器后立即返回到浏览器。虽然消息发送到浏览器仍需要50ms,但由于WebSocket连接始终保持在打开状态,就不需要重复向服务器发送请求了。
HTML5 WebSockets在实时Web的扩展性方面取得了巨大的进步。正如在本章中看到的,根据HTTP消息头大小的影响, HTML5 WebSocke也可以实现500:1甚至1000: 1 的HTTP消息头流量缩减,并实现3:1的通信延迟缩减。
6.2 HTML5 WebSockets的浏览器支持情况
如表6-1所示,在编写本书的时候,以下浏览器已经支持或计划支持HTML5 WebSockets。
浏览器 | 支持情况 |
Chrome | 版本4以上支持 |
Firefox | 版本4以上支持 |
Internet Explorer | 暂不支持 |
Opera | 暂不支持 |
Safari | 版本5以上支持 |
由于浏览器支持程度有所不同,所以在使用HTML5 WebSockets元素之前,最好先检查一下浏览器是否支持它。本章6.4.1小节中将介绍如何用代码方式检测浏览器的支持情况。
6.3 编写简单的Echo WebSocket服务器
在使用WebSockets API之前,开发人员还需要一个支持WebSocket 的服务器。本节我们会演示如何通过简单的步骤完成ebSocket “Echo” 服务器的编写。为了运行本章中的示例,我们引入了一个筒单的ebSockets服务器,它是用Python编写的。下面示例的代码位于本书网站上的WebSockets部分。
现有WebSocket服务器
现成的WebSocket 服务器有很多,还在开发中的就更多了。以下是几种现有的WebSocket服务器。
- Kaazing WebSocket Gateway: 一种基于Java的WebSocket网关。
- mod_pywebsocket: 一种基于Python的Apache HTTP服务器扩展。
- Netty: 一种包含WebSocket 的Java框架。
- Node.js: 一种驱动多个WebSocket服务器的服务器端lavaScript 框架。
对于非原生支持WebSocket 的浏览器来说,Kaazing 的WebSocket网关包含了完整的客户将浏览器WebSocket模拟支持,这样,你就可以在其基础上进行WebSocket API编程了,同时生成的代码可以运行在所有浏览器上。
为接收ws:/localhost:8080/echo上的连接,需要启动Python WebSocket Echo服务器。打开命令行窗口,转到该文件所在的文件突,然后执行以下命令:
tython websocket .py
我们还引入了一个广播服务器(broadcast server,用以接收ws:/localhost:8080/broadcast上的连接。与Ecbo 服务器不同,发往这个特定服务器端的任何WebSocket消息都会被广播给所有当前连接的客户端。将消息广播给多个监听者非常简单。为了启动广播服务器,打开命令行窗口,转到该文件所在的文件夹,然后执行以下命令:
tython websocket .py
以上两个脚本都用到了WebSocket.py 中的WebSocket 协议库, 要实现其他的服务器端行为,可以在相应的路径下添加相应的处理代码。
提示:这个服务器只能处理WebSocket协议,无法响应HTTP请求。因为WebSocket连接衍生于HTTP的子集,而且使用升级过的报头, 所以其他很多服务器都可以使用同一端口响应WebSocke和HTTP请求。
接下来,看看当浏览器与这个服务器通信时会怎么样。浏览器向WebSocket URL发出一个请求,服务器会返回报头来完成WebSocket握手。WebSocket 握手响应必须以HTTP//1 .1 101WebSocket Protocol Handshalce开头。实际上,握手报头的内容和顺序要比HTTP报头定义得更加严格。
提示:如果你想要自己实现WebSocket服务器,可以参考http://tools.ietf.org/html/drafthixie-thewebsocketprotocol中共不的IETF协议草案或是最新技术规范。
//填写我们自己的响应报头 self.send_bytes("HTTP/1.1 101 Web Socket Protocol Handshake\r\n") self.send_bytes("Upgrade: WebSocket\r\n") self.send_bytes("Connection: Upgrade\r\n") self.send_bytes("Sec-WebSocket-Origin: %s\r\n" % headers["Origin"]) self.send_bytes("Sec-WebSocket-Location: %s\r\n" % headers["Location"]) if "Sec-WebSocket-Protocol" in headers: protocol = headers["Sec-WebSocket-Protocol"] self.send_bytes("Sec-WebSocket-Protocol: %s\r\n" % protocol) self.send_bytes("\r\n") //回写哈希处理后的响应token self.send_bytes(response_token)
握手完成之后,客户端和服务嚣就可以随时发送消息了。在服务器上,每个连接都通过一个WebSoclcetConnection 实例来表示,WebSocketConnection中的send()函数会把字符串转换成WebSocket协议字符串。UFT-8格式编码的字符串以0x00和0XFF字节作为帧边界。在该服务器上,每个WebSocket连接都是一个支持缓冲发送的异步封装程序(asyncore.dispatcher_with_send) 。
提示:Python等语言还有很多其他的异步I/O框架。之所以选Asyncore,是因为他包含在Python标准库当中。另外要注意到是,这个实现中可支持WebSocket协议的75和76版本。严格来说,不允许同时支持两个版本。这里只是为了做个简单的演示。
WebSocketConnection 继承自asynore.dispatcher_with_send,并将send()函数重写以支持UTF-8编码字符串,并且添加了WebSocket字符串帧。
def send(self, s): if self.readystate == "open": self.send_bytes("\x00") self.send_bytes(s.encode("UTF8")) self.send_bytes("\xFF")
Websocket.py文件中WebsockeConnections的处理程序沿用来简易的调度接口。连接请求到达时,调用处理程序中的dispatch()方法来为各帧分发有效载荷。EchoHandler方法会将各条消息返回给发送者。
class EchoHandler(object): """ The EchoHandler repeats each incoming string to the same WebSocket. """ def __init__(self, conn): self.conn = conn def dispatch(self, data): self.conn.send("echo: " + data)
基本广播服务器材。broadcast.py的工作原理大量相同,但当广播程序接收到字符串时,会返回给所有连接着的WebSocket,如下所示。
class BroadcastHandler(object): """ The BroadcastHandler repeats incoming strings to every connected WebSocket. """ def __init__(self, conn): self.conn = conn def dispatch(self, data): for session in self.conn.server.sessions: session.send(data)
broadcast.py中的处理程序提供了一种轻量级的消息广播器,它只负责简单地发送和接收字符串,对我们的示例而言这已经足够了。需要注意,广播服务器并不对输入进行任何验证,但产品化的消息服务器中这种验证是必需的。产品化的WebSocket服务器至少要对输入数据的格式进行验证。
最后,代码清单6-5 和代码清单6-6分别提供了websocke.py和broadcast.py的完整代码。不过代码只是用来演示一个服务器的实现,并不适用于实际部署。
代码清单6-5 websocke.py的完整代码
#!/usr/bin/env python import asyncore import socket import struct import time import hashlib class WebSocketConnection(asyncore.dispatcher_with_send): def __init__(self, conn, server): asyncore.dispatcher_with_send.__init__(self, conn) self.server = server self.server.sessions.append(self) self.readystate = "connecting" self.buffer = "" def handle_read(self): data = self.recv(1024) self.buffer += data if self.readystate == "connecting": self.parse_connecting() elif self.readystate == "open": self.parse_frametype() def handle_close(self): self.server.sessions.remove(self) self.close() def parse_connecting(self): header_end = self.buffer.find("\r\n\r\n") if header_end == -1: return else: header = self.buffer[:header_end] # remove header and four bytes of line endings from buffer self.buffer = self.buffer[header_end+4:] header_lines = header.split("\r\n") headers = {} # validate HTTP request and construct location method, path, protocol = header_lines[0].split(" ") if method != "GET" or protocol != "HTTP/1.1" or path[0] != "/": self.terminate() return # parse headers for line in header_lines[1:]: key, value = line.split(": ") headers[key] = value headers["Location"] = "ws://" + headers["Host"] + path self.readystate = "open" self.handler = self.server.handlers.get(path, None)(self) if "Sec-WebSocket-Key1" in headers.keys(): self.send_server_handshake_76(headers) else: self.send_server_handshake_75(headers) def terminate(self): self.ready_state = "closed" self.close() def send_server_handshake_76(self, headers): """ Send the WebSocket Protocol v.76 handshake response """ key1 = headers["Sec-WebSocket-Key1"] key2 = headers["Sec-WebSocket-Key2"] # read additional 8 bytes from buffer key3, self.buffer = self.buffer[:8], self.buffer[8:] response_token = self.calculate_key(key1, key2, key3) # write out response headers self.send_bytes("HTTP/1.1 101 Web Socket Protocol Handshake\r\n") self.send_bytes("Upgrade: WebSocket\r\n") self.send_bytes("Connection: Upgrade\r\n") self.send_bytes("Sec-WebSocket-Origin: %s\r\n" % headers["Origin"]) self.send_bytes("Sec-WebSocket-Location: %s\r\n" % headers["Location"]) if "Sec-WebSocket-Protocol" in headers: protocol = headers["Sec-WebSocket-Protocol"] self.send_bytes("Sec-WebSocket-Protocol: %s\r\n" % protocol) self.send_bytes("\r\n") # write out hashed response token self.send_bytes(response_token) def calculate_key(self, key1, key2, key3): # parse keys 1 and 2 by extracting numerical characters num1 = int("".join([digit for digit in list(key1) if digit.isdigit()])) spaces1 = len([char for char in list(key1) if char == " "]) num2 = int("".join([digit for digit in list(key2) if digit.isdigit()])) spaces2 = len([char for char in list(key2) if char == " "]) combined = struct.pack(">II", num1/spaces1, num2/spaces2) + key3 # md5 sum the combined bytes return hashlib.md5(combined).digest() def send_server_handshake_75(self, headers): """ Send the WebSocket Protocol v.75 handshake response """ self.send_bytes("HTTP/1.1 101 Web Socket Protocol Handshake\r\n") self.send_bytes("Upgrade: WebSocket\r\n") self.send_bytes("Connection: Upgrade\r\n") self.send_bytes("WebSocket-Origin: %s\r\n" % headers["Origin"]) self.send_bytes("WebSocket-Location: %s\r\n" % headers["Location"]) if "Protocol" in headers: self.send_bytes("WebSocket-Protocol: %s\r\n" % headers["Protocol"]) self.send_bytes("\r\n") def parse_frametype(self): while len(self.buffer): type_byte = self.buffer[0] if type_byte == "\x00": if not self.parse_textframe(): return def parse_textframe(self): terminator_index = self.buffer.find("\xFF") if terminator_index != -1: frame = self.buffer[1:terminator_index] self.buffer = self.buffer[terminator_index+1:] s = frame.decode("UTF8") self.handler.dispatch(s) return True else: # incomplete frame return false def send(self, s): if self.readystate == "open": self.send_bytes("\x00") self.send_bytes(s.encode("UTF8")) self.send_bytes("\xFF") def send_bytes(self, bytes): asyncore.dispatcher_with_send.send(self, bytes) class EchoHandler(object): """ The EchoHandler repeats each incoming string to the same Web Socket. """ def __init__(self, conn): self.conn = conn def dispatch(self, data): self.conn.send("echo: " + data) class WebSocketServer(asyncore.dispatcher): def __init__(self, port=80, handlers=None): asyncore.dispatcher.__init__(self) self.handlers = handlers self.sessions = [] self.port = port self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.set_reuse_addr() self.bind(("", port)) self.listen(5) def handle_accept(self): conn, addr = self.accept() session = WebSocketConnection(conn, self) if __name__ == "__main__": print "Starting WebSocket Server" WebSocketServer(port=8080, handlers={"/echo": EchoHandler}) asyncore.loop()
从代码那个可以看到,WebSocket握手中有一个特殊的关键值计算。这是为了防止跨协议(cross-protocol)攻击。简而言之,这种机制能够阻止恶意的WebSocket客户端代码连接到非WebSocket服务器。截止草案76,握手中的这部分设计仍在讨论中。
代码清单6-6 broadcast.py的完整代码
#!/usr/bin/env python import asyncore from websocket import WebSocketServer class BroadcastHandler(object): """ The BroadcastHandler repeats incoming strings to every connected WebSocket. """ def __init__(self, conn): self.conn = conn def dispatch(self, data): for session in self.conn.server.sessions: session.send(data) if __name__ == "__main__": print "Starting WebSocket broadcast server" WebSocketServer(port=8000, handlers={"/broadcast": BroadcastHandler}) asyncore.loop()
现在我们有了可用的Echo服务器,下一步需要编写客户端程序。
6.4 使用HTML5 WebSockets API
本节我们将更为详细地探讨HTML5 WebSockets的使用方法。
6.4.1 浏览器支持情况检测
在使用HTML5 WebSockets API之前,首先需要确认浏览器的支持情况。如果浏览器不支持,我们可以提供一些替代信息,提示用户升级浏览器。代码清单6-7是检测浏览器支持情况的一种方法。
代码清单6-7 检测浏览器支持情况
function loadDemo(){ if (window.WebSocket){ document.getElementById(“support”).innerHTML=“HTML5 WebSocket is supported in your browser.”; document.getElementById(“support”).innerHTML=“HTML5 WebSocket is not supported in your browser.”; } }
上面的示例代码使用LoadDemo 函数检测浏览器支持性,该函数会在页面加载时被调用。若存在WebSocket对象。调用window . WebSocKet 就会将其返回,否则将触发异常失败处理。然后,根据检测结果更新页面显示。由于页面代码中预定义了support 元素,将适当的信息显示在此元素中就可以从页面上反映出浏览器的支持情况。
检测浏览器是否支持HTML5 WebSocket的另一种方法是使用浏览器控制台(如Firebug或Chrome开发工具)。图6-5是在Google Chrome中检测自身是否支持WebSocket{若不支持,window . WebSocket 命令将返回”undefined”)。
图6-5 通过Google Chrome开发工具来检测WebSocket支持性
6.4.2 API的基本用法
以下示例代码均位于本书网站上的WebSocke部分。websocket.html 文件、broadcast.html文件(还包括下面一节中用到的tracker.html 文件) ,以及前面提到的可在Python中启动的WebSocket服务器的代码都在同一个文件夹中。
1.、WebSocket对象的创建及其与WebSocket服务器的连接
WebSocket接口的使用非常简单。要连接通信端点,只需要创建一个新的WebSocket实例,并提供希望连接的对端URL。wss://和wss://前缀分别表示WebSocket连接和安全的悦WebSocket连接。
Url = “ws://localhost:8080/echo”; W = new WedSocket(url);
2、添加事件监听器
WedSocket编程遵循异步编程模型$;打开socket后,只需要等待事件发生,而不需要主动向服务器轮询,所以需要在WebSocket对象中添加回调函数来监听事件。
WebSocket 对象有三个事件: open. close和message。当连接建立时触发open事件。当收到消息时触发message事件,当WebSocket连接关闭时触发close事件。同大多数JavaScript API 一样,事件处理时会调用相应的(onopen、onmessage 、onclose) 回调函数。
w.onopen = function() { log("open"); w.send("thank you for accepting this websocket request"); } w.onmessage = function(e) { log(e.data); } w.onclose = function(e) { log("closed"); }
3、发送消息
当socket 处于打开状态(即调用onopen 监听程序之后,调用onclose 监听程序之前),可以采用send 方法来发送消息。消息友送完成之后,可以调用close 方法来终止连接,当然也可以不这么做,让其保持打开状态。
document.getElementById(“sendButton”).onclick = function(){ w.send(document.getElementById(“inputMessage”).value). }
浏览器双向通信就这么简单。为完整起见,代码清单6-8列出了带有WebSocket的整个HTML顶面代码。
代码清单6-8 websocket.html代码
<!DOCTYPE html> <title>WebSocket Test Page</title> <script> var log = function(s) { if (document.readyState !== "complete") { log.buffer.push(s); } else { document.getElementById("output").innerHTML += (s + "\n"); } } log.buffer = []; url = "ws://localhost:8080/echo"; w = new WebSocket(url); w.onopen = function() { log("open"); w.send("thank you for accepting this WebSocket request"); } w.onmessage = function(e) { log(e.data); } w.onclose = function(e) { log("closed"); } window.onload = function() { log(log.buffer.join("\n")); document.getElementById("sendButton").onclick = function() { w.send(document.getElementById("inputMessage").value); } } </script> <input type="text" id="inputMessage" value="Hello, WebSocket!"><button id="sendButton">Send</button> <pre id="output"></pre>
4、运行WebSocket页面
为了测试带有WebSocket代码的websocket.html页面。请打开命令行窗口,转到WebSocket代码所在目录下,输入以下命令启动服务器:
Python - m simpleHTTPServer 9999
下一步,打开另一命令行窗口,转到WebSocket 代码所在目录,输入以下命令来启动Python WebSoket 服务:
Python websocket.py
最后,打开支持WebSocket的浏览器,浏览http://localhost:9999/websocket.thml。图6-6为运行起来的页面。
图6-6为运行起来的页面。
示例代码文件夹中还包含一个Web页面,可以连接到之前创建的broadcast服务。操作步骤如下:先将正在运行WebSocket服务器的命令行窗口关闭,然后转到WebSocket 代码所在目录,输入以下命令启动PythonWebSocket服务器。
Python broadcast.py
分别打开连个支持WebSockets的浏览器,统一赚到http://localhost:9999/broadcast.thml。
图6-7所示为broadcast WebSocket服务器在两个独立的Web页面中的运行情况。
图6-7 两个浏览器中运行的broadcast.html
6.5 创建HTML5 WebSockets应用程序
我们已经了解WebSocket的基本知识,现在可以动手实践一下了。之前,我们使用HTML5 Geolocation接口创建了一个直接在Web页面中计算距离的应用。我们可以利用同样的Geolocation技术,结合对WebSocket的支持,创建一个支持多方通信的简单应用:位置跟踪器。
提示:我们需要用到上面介绍的broadcast WebSocket服务器,如果对此还不是很熟悉,则建议先花一些时间来学习相关的基础知识。
在这个示例应用中,我们会将WebSocket和Geolocation技术相结合,以便确定用户位置并将其广播给所有有效的监听者,所有加载该应用并连接到同一broadcast 服务器的用户都将通过WebSocket来定期发送他们的位置信息。同时,该应用还将监听所有来自服务器的消息,并将其向所有监听者实时地更新显示,在长跑比赛中,此类应用可以使参赛者知道所有参赛选手的位置,并促使他们加快速度(或是减慢速度)。
除经度和纬度位置之外,这个小应用不包含任何个人信息。姓名、生日、喜欢的冰棋淋口味等都是严格保密的。
警告用户
“这是一个关于个人信息共享的应用程序。在应用首次访问Geolocation API 的时候,浏览器会为用户弹出一个提示框。诚然,这里只共享了位置信息。但是,如果用户没明白浏览器警告的意思,那么这个应用将会非常地给他上一课,他会意识到将敏感信息传送到远端有多么容易。请确保用户充分理解位置信息提交的后果。
如采用户对此有疑问,我们需要在应用中多做一些相关的工作,提前告知用户他们的很感数据会被如何使用。告知方式应尽量简洁。“
——Brian
敬告过后让我们深入探讨代码。同样地,完整代码示例都在网上,以供细读。这里我们将关注其中最重要的部分。应用程序的最终效果如图6-8 所示,理论上还可以通过叠加在地图上来对其进行优化。
图6-8 位置跟踪器应用
6.5.1 编写HTML文件
示例中的HTML标签专门进行了简化,这样有助于突出手边的数据。有多简单呢?
<body onload="loadDemo()"> <h1>HTML5 WebSocket / Geolocation Tracker</h1> <div><strong>Geolocation</strong>: <p id="geoStatus">HTML5 Geolocation is <strong>not</strong> supported in your browser.</p></div> <div><strong>WebSocket</strong>: <p id="socketStatus">WebSockets are <strong>not</strong> supported in your browser.</p></div> </body>
简单到只包含一个标题和一些状态区域:一个拥有更新Geolocation,另一个用于记录WebSocket行为日志。这样,当收到实时消息时。就可将实际位置数据插入到页面中。
默认情况下,状态信息会显示拥有的浏览器不支持Geolocation或WebSocket。一旦检测到可以支持这两种HTML5技术,就采用更为友好的方式来更新状态信息。
<script> // reference to the WebSocket var socket; // a semi-unique random ID for this session var myId = Math.floor(100000*Math.random()); // number of rows of data presently displayed var rowCount = 0;
我们再次通过脚本实现了应用功能。首先,定义几个变量。
- socket : 全局变量,方便各种函数由问。
- myld : 用于在线表示位置数据,介于0~100 000之间的随机数。这个数字仅用来表示相对位置变化,而无需使用名字之类的其他个人信息。足够大的数据池可保证每个人的标识唯一。
- rowCount: 表示有多少用户将其位置数据发送给我们,主要用于可视化显示。
下面两个函数看起来应该比较眼熟。与其他示例应用一样,他们用来辅助更新状态信息。有两个状态信息需要更新。
function updateSocketStatus(message) { document.getElementById("socketStatus").innerHTML = message; } function updateGeolocationStatus(message) { document.getElementById("geoStatus").innerHTML = message; }
当获取位置出现异常时,有必要时只对拥有友好的异常提醒信息。如需了解更多的Geolocation异常处理信息,轻查阅第4章。
function handleLocationError(error) { switch(error.code) { case 0: updateGeolocationStatus("There was an error while retrieving your location: " + error.message); break; case 1: updateGeolocationStatus("The user prevented this page from retrieving a location."); break; case 2: updateGeolocationStatus("The browser was unable to determine your location: " + error.message); break; case 3: updateGeolocationStatus("The browser timed out before retrieving the location."); break; } }
6.5.2 添加WebSocket 代码
现在,一起来看一下核心功能代码。页面初始加载时会调用loadDemo函数,它是应用程序的起点。
function loadDemo() { // test to make sure that sockets are supported if (window.WebSocket) { // the location of our broadcast WebSocket server url = "ws://localhost:8080"; socket = new WebSocket(url); socket.onopen = function() { updateSocketStatus("Connected to WebSocket tracker server"); } socket.onmessage = function(e) { updateSocketStatus("Updated location from " + dataReturned(e.data)); } }
首先是创建WebSocket连接。同其他HTML5技术一样,在使用之前.需要进行浏览器支持情况检测,因此代码一开始测试了浏览器是否支持window. WebSocket对象。
一且验证通过,就可以使用之前提到的连接字符串格式来与远程广播服务器建立连接。连接会保存在我们声明的socket 全局变量中。
最后,我们建立了两个函数,用来在WebSocket收到更新消息时执行相关操作。onopen函数只负责更新状态信息以告知用户已成功建立连接。类似地。onmessage 函数只负责更新状态信息,以告知用户消息已送达。onmessage函数同时还会调用dataRetu rned 函数以便在页面上显示送达的消息内容。dataReturned 函数稍后再进行讨论。
6.5.3 添加Geolocation代码
读过第4 章后,下面的代码应该就不陌生了。这里,我们要检测Geolocation服务的浏览器支持情况, 并对状态信息做相应更新。
var geolocation; if(navigator.geolocation) { geolocation = navigator.geolocation; updateGeolocationStatus("HTML5 Geolocation is supported in your browser."); } // register for position updates using the Geolocation API geolocation.watchPosition(updateLocation, handleLocationError, {maximumAge:20000}); }
和第4 章中一样,我们监控当前位置的变化,并注册处理函数保证在位置发生变化时,updateLocation函数会被调用. 错误信息会发送至handleLocationError函数,位置信息则设置为每20s更新一次。
当新位置可用时,浏览器将调用一下函数。
function updateLocation(position) { var latitude = position.coords.latitude; var longitude = position.coords.longitude; var timestamp = position.timestamp; updateGeolocationStatus("Location updated at " + timestamp); // Send my location via WebSocket var toSend = JSON.stringify([myId, latitude, longitude]); sendMyLocation(toSend); }
这部分着起来与第4章中非常类似,但要简单得多。这里,我们从浏览器提供的位置信息中提取了经度、纬度和时间戳信息,然后更新状态消息,表明新的位置信息已经到这。
6.5.4 合并所有内容
最后一段代码的得出来用于发送给远程broadcast WebSocket服务器的消息字符串,它采用JSON编码格式:
“[<id>,<latitude>,<longitude>]”
其中id是一个用来表示用户的随机数,latitude和Longitude是由Geolocation位置对象提供的。我们将JSON格式编码的字符串位置信息发送给服务器。
通过sendMyLocation()函数可将位置信息发送给服务器。
function sendMyLocation(newLocation) { if (socket) { socket.send(newLocation); } }
在socket已成功创建(并为后续访问保留)的情况下,通过这个函数将消息字符串发送到服务器是安全的。消息到达后, WebSockct消息广播服务器将把位置信息分发给处于连接状态且正在监听消息的每一个浏览器。这样,每个入都能知道你在哪里,至少通过随机数标识出的大量匿名用户能让人了解你的动向。
发送消息之后,让我们看着这些相同消息抵达时,浏览器将如何处理。回想一下,我们已在socket上注册了。omessage 函数,其作用是将所有输入数据传送到dataReturned() 函数。下一步,我们来详细分析这个函数。
function dataReturned(locationData) { // break the data into ID, latitude, and longitude var allData = JSON.parse(locationData); var incomingId = allData[1]; var incomingLat = allData[2]; var incomingLong = allData[3];
dataReturned()函数有两个功能。它将在页画中创建(或更新)显示元素, 以便呈现收到的消息字符串中的位置信息,同时返回标明了消息来源于哪个用户的文本信息。随后socket.onmessage函数会将其中的用户名显示在页面上方状态信息中。
数据处理函数的第一步操作是使用JSON.parse对收到的数据进行分解。尽管从健壮性角度考虑,还应该检查数据格式是否非法,但是作为示例,此处假设所有消息格式都是合格的。因此,字符串会被干净利落地分解成随机ID、纬度和经度。
// locate the HTML element for this ID // if one doesn't exist, create it var incomingRow = document.getElementById(incomingId); if (!incomingRow) { incomingRow = document.createElement('div'); incomingRow.setAttribute('id', incomingId);
当收到一条消息时,用户界面会为每个随机ID创建一个可见的<div>。用户的ID也会显示在里面,换句话说,当前用户自己的数据发送出去并从WebSocket 广播服务器返回后,也会被显示在页面上。
因此,我们首先要通过消息字符事中的ID来定位它所对应的行元素。如果不存在。就创建一个,并将其id 值设为从socket服务器返回的id值,以备后用。
incomingRow.userText = (incomingId == myId) ? 'Me' : 'User ' + rowCount; rowCount++;
数据行中显示什么样的拥有文本内容很容易判断。如果ID与用户ID一致,就显示为“me”。否则,用户名由一个公共字符串和一个每次加1的行数组合而成。
document.body.appendChild(incomingRow); }
新的显示元素就绪后,就会被插入到页面最后。不管显示元素是新建的还是已经存在的(如果不是某个用户的第一次位置更新,那相应的显示元素就已经存在了) .都需要根据当前的文本信息更新它的内容。
// update the row text with the new values incomingRow.innerHTML = incomingRow.userText + " \\ Lat: " + incomingLat + " \\ Lon: " + incomingLong; return incomingRow.userText; }
在这个示例中, 我们通过一个反斜杠{当然,经过了转义处理)来分隔用户名文本信息和经、纬度信息。最后,为了更新状态行。用于显示的用户名文本将返回给调用函数。
WebSocket和Geolocation的组合程序已经完成。但要注意。只有当多个浏览器同时访问应用时,你才能看到多条更新信息。读者不妨做个练习,改造示倒代码,让位置信息显示在Goog1e地图上,然后,你就会明白HTML5的奥妙了。
6.5.5 最终代码
代码清单6-9给出了tracker.html文件中的源代码。
代码清单6-9 tracker.html
<!DOCTYPE html> <html lang="en"> <head> <title>HTML5 WebSocket / Geolocation Tracker</title> <link rel="stylesheet" href="styles.css"> </head> <body onload="loadDemo()"> <h1>HTML5 WebSocket / Geolocation Tracker</h1> <div><strong>Geolocation</strong>: <p id="geoStatus">HTML5 Geolocation is <strong>not</strong> supported in your browser.</p></div> <div><strong>WebSocket</strong>: <p id="socketStatus">WebSockets are <strong>not</strong> supported in your browser.</p></div> <script> // reference to the WebSocket var socket; // a semi-unique random ID for this session var myId = Math.floor(100000*Math.random()); // number of rows of data presently displayed var rowCount = 0; function updateSocketStatus(message) { document.getElementById("socketStatus").innerHTML = message; } function updateGeolocationStatus(message) { document.getElementById("geoStatus").innerHTML = message; } function handleLocationError(error) { switch(error.code) { case 0: updateGeolocationStatus("There was an error while retrieving your location: " + error.message); break; case 1: updateGeolocationStatus("The user prevented this page from retrieving a location."); break; case 2: updateGeolocationStatus("The browser was unable to determine your location: " + error.message); break; case 3: updateGeolocationStatus("The browser timed out before retrieving the location."); break; } } function loadDemo() { // test to make sure that sockets are supported if (window.WebSocket) { // the location where our broadcast WebSocket server is located url = "ws://localhost:8080"; socket = new WebSocket(url); socket.onopen = function() { updateSocketStatus("Connected to WebSocket tracker server"); } socket.onmessage = function(e) { updateSocketStatus("Updated location from " + dataReturned(e.data)); } } var geolocation; if(navigator.geolocation) { geolocation = navigator.geolocation; updateGeolocationStatus("HTML5 Geolocation is supported in your browser."); } // register for position updates using the Geolocation API geolocation.watchPosition(updateLocation, handleLocationError, {maximumAge:20000}); } function updateLocation(position) { var latitude = position.coords.latitude; var longitude = position.coords.longitude; var timestamp = position.timestamp; updateGeolocationStatus("Location updated at " + timestamp); // Send my location via WebSocket var toSend = JSON.stringify([myId, latitude, longitude]); sendMyLocation(toSend); } function sendMyLocation(newLocation) { if (socket) { socket.send(newLocation); } } function dataReturned(locationData) { // break the data into ID, latitude, and longitude var allData = JSON.parse(locationData) var incomingId = allData[1]; var incomingLat = allData[2]; var incomingLong = allData[3]; // locate the HTML element for this ID // if one doesn't exist, create it var incomingRow = document.getElementById(incomingId); if (!incomingRow) { incomingRow = document.createElement('div'); incomingRow.setAttribute('id', incomingId); incomingRow.userText = (incomingId == myId) ? 'Me' : 'User ' + rowCount; rowCount++; document.body.appendChild(incomingRow); } // update the row text with the new values incomingRow.innerHTML = incomingRow.userText + " \\ Lat: " + incomingLat + " \\ Lon: " + incomingLong; return incomingRow.userText; } </script> </body> </html>
6.6 小结
本章,我们演示了如何使用HTML5 WebSockets 创建引人注目的实时应用,见证了其开发有多么简单,功能又是多么强大。
首先,我们了解了HTML5 WebSockets 协议本身的特性,以及它对现有HTTP?流量方面的影响。我们从网络开销方面对比丁基于轮询的通信策略和WebSocket通信技术。
然后,我们搭建了一个简单的WebSocket服务器来演示WebSocket的运行情况,从整个搭建过程中可以看到WebSocket协议在实际使用中非常简单,类似地,我们还讨论了客户端的WebSocket API。发现它也很容品与现有JavaScript程序集成。
最后,我们创建了一个相对复杂的示例应用,它综合了Geolocation和WebSocket 的能力,这也说明两种技术完全能够良好地协同工作。
现在, 我们已经了解了HTML5为浏览器带来的socket式的网络编程技术。下一步,我们会把注意力转移到收集更多有用的数据上,而不仅仅是用户的当前位置信息。下一章,我们将深人了解HTML5在表单控件方面的改进。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论