编者按:InfoQ 开设新栏目“品味书香”,精选技术书籍的精彩章节,以及分享看完书留下的思考和收获,欢迎大家关注。本文节选自 Alex MacCaw 著,李晶、张散集译的《基于 MVC 的 JavaScript Web 富应用开发》中的第 8 章“实时 Web”。
为什么实时 Web 这么重要?我们生活在一个实时(real-time)的世界中,因此 Web 的最终最自然的状态也应当是实时的。用户需要实时的沟通、数据和搜索。我们对互联网信息实时性的要求也越来越高,如果信息或消息延时几分钟后才更新,简直让人无法忍受。现在很多大公司(如 Google、Facebook 和 Twitter)已经开始关注实时 Web,并提供了实时性服务。实时 Web 将是未来最热门的话题之一。
一、实时 Web 的发展历史
传统的 Web 是基于 HTTP 的请求 / 响应模型的:客户端请求一个新页面,服务器将内容发送到客户端,客户端再请求另外一个页面时又要重新发送请求。后来有人提出了 AJAX,AJAX 使得页面的体验更加“动态”,可以在后台发起到服务器的请求。但是,如果服务器有更多数据需要推送到客户端,在页面加载完成后是无法实现直接将数据从服务器发送给客户端的。实时数据无法被“推送”给客户端。
为了解决这个问题,有人提出了很多解决方案。最简单(暴力)的方案是用轮询:每隔一段时间都会向服务器请求新数据。这让用户感觉应用是实时的。实际上这会造成延时和性能问题,因为服务器每秒都要处理大量的连接请求,每次请求都会有 TCP 三次握手并附带 HTTP 的头信息。尽管现在很多应用仍在使用轮询,但这并不是最理想的解决方案。
后来随着 Comet 技术的提出,又出现了很多更高级的解决方案。这些技术方案包括永久帧(forever frame)、XHR 流(xhr-multipart)、htmlfile,以及长轮询。长轮询是指,客
户端发起一个到服务器的 XHR 连接,这个连接永不关闭,对客户端来说连接始终是挂起状态。当服务器有新数据时,就会及时地将响应发送给客户端,接着再将连接关闭。然后重复整个过程,通过这种方式就实现了“服务器推”(server push)。
Comet 技术是非标准的 hack 技术,正因为此,浏览器端的兼容性就成了问题。首先,性
98 能问题无法解决,向服务器发起的每个连接都带有完整的 HTTP 头信息,如果你的应用需要很低的延时,这将是一个棘手的问题。当然不是说 Comet 本身有问题,因为还没有其他替代方案前 Comet 是我们的唯一选择。
浏览器插件(如 Flash)和 Java 同样被用于实现服务器推。它们可以基于 TCP 直接和服务器建立 socket 连接,这种连接非常适合将实时数据推给客户端。问题是并不是所有的浏览器都安装了这些插件,而且它们常常被防火墙拦截,特别是在公司网络中。
现在 HTML5 规范为我们准备了一个替代方案。但这个规范稍微有些超前,很多浏览器都还不支持,特别是 IE,对于现在很多开发者来说帮助不大,鉴于大部分浏览器还未实现 HTML5 的 WebSocket,现行最好的办法仍然是使用 Comet。
二、WebSocket
WebSocket( http://dev.w3.org/html5/websockets )是 HTML5 规范( http://www.w3.org/TR/html5 )的一部分,提供了基于 TCP 的双向的、全双工的 socket 连接。这意味着服务器可以直接将数据推送给客户端,而不需要开发者求助于长轮询或插件来实现,这是一个很大的进步。尽管有一些浏览器实现了 WebSocket,但由于一些安全问题没有解决,因此协议( http://goo.gl/F7lvW )仍然在修订之中。然而这不会阻碍我们的脚步,这些安全问题属于技术性问题,会很快被修复,WebSocket 很快就会成为最终规范。与此同时,对于那些不支持 WebSocket 的浏览器,可以降级使用笨方法来实现,比如 Comet 或轮询。
和之前的服务器推的技术相比,WebSocket 有着巨大的优势,因为 WebSocket 是全双工的,而不是基于 HTTP 的,一旦建立连接就不会断掉。Comet 所面对的现实问题就是 HTTP 的体积太大,每个请求都带有完整的 HTTP 头信息。而且包含很多没有用的 TCP 握手,因为 HTTP 是比 TCP 更高层次的网络协议。
使用 WebSocket 时,一旦服务器和客户端之间完成握手,信息就可以畅通无阻地随意往来于两端,而不用附加那些无用的 HTTP 头信息。这极大地降低了带宽的占用,提高了性能。因为连接一直处于活动状态,服务器一旦有新数据要更新时就可以立即发送给客户端(不需要客户端先请求,服务器再响应了)。另外,连接是双工的,因此客户端同样可以发送数据给服务器,当然也不需要附带多余的 HTTP 头。
下面这段话出自 Google 的 Ian Hickson,HTML5 规范小组负责人,它是这样描述 WebSocket 的:
将千字节的数据降为 2 字节……并将延时从 150 毫秒降为 50 毫秒,这种优化跨越了不止一个量级,实际上仅这两点优化就足以让 Google 确信 WebSocket 会给产品带来非一般的用户体验。
现在我们来看一下都有哪些浏览器支持 WebSocket:
- Chrome >= 4
- Safari >= 5
- iOS >= 4.2
- Firefox >= 4*
- Opera >= 11*
尽管 Firefox 和 Opera 也都实现了 WebSocket,但考虑到 WebSocket 仍然存在安全隐患,默认并没有启用它。但这不是什么大问题,或许本书出版时 WebSocket 的安全问题就已经解决了。同时你也可以在那些对 WebSocket 支持不好的浏览器中进行降级处理,使用诸如 Comet 和 Flash 的笨方法。
检测浏览器是否支持 WebSocket 也非常简单、直接:
<span>var</span> supported = (<span>"WebSocket"</span> <span>in</span> window); <span>if</span> (supported) alert(<span>"WebSockets are supported"</span>);
长远来看,浏览器的 WebSocket API 非常清晰且合乎逻辑。可以使用 WebSocket 类来实例化一个新的套接字(socket),这需要传入服务器的端地址,在这个例子中是 ws://example.com:
<span>var</span> socket = <span>new</span> WebSocket(<span>"ws://example.com"</span>);
然后我们需要给这个套接字添加事件监听 :
socket.onopen = <span><span>function</span><span>()</span>{</span> } socket.onmessage = <span><span>function</span><span>(data)</span>{</span> } socket.onclose = <span><span>function</span><span>()</span>{</span> }
当服务器发送一些数据时,就会触发 onmessage 事件,同样,客户端也可以调用 send()
函数将数据传回服务器。很明显,我们应当在连接建立且触发了 onopen 事件之后调用它:
socket.onmessage = <span><span>function</span><span>(msg)</span>{</span> console.log(<span>"New data - "</span>, msg); }; socket.onopen = <span><span>function</span><span>()</span>{</span> socket.send(<span>"Why, hello there"</span>). };
发送和接收的消息只支持字符串格式。但在字符串和 JSON 数据之间可以很轻松地相互转换,这样就可以创建你自己的协议:
<span>var</span> rpc = { test: <span><span>function</span><span>(arg1, arg2)</span> {</span> } }; socket.onmessage = <span><span>function</span><span>(data)</span>{</span> <span>var</span> msg = <span>JSON</span>.parse(data); rpc[msg.method].apply(rpc, msg.args); };
这段代码中,我们创建了一个远程过程调用(remote procedure call,RPC)脚本,服务器可以发送一些简单的 JSON 来调用客户端的函数,就像下面这行代码:
{<span>"method"</span>: <span>"test"</span>, <span>"args"</span>: [<span>1</span>, <span>2</span>]}
注意,这里的调用是限制在 rpc 对象里的。这样做的原因主要是出于安全考虑,如果允许在客户端执行任意 JavaScript 代码,黑客就会利用这个漏洞。可以调用 close() 函数来关闭这个连接:
<span>var</span> socket = <span>new</span> WebSocket(<span>"ws://localhost:8000/server"</span>);
你肯定注意到了我们在实例化一个 WebSocket 的时候使用了 WebSocket 特有的协议前缀 ws://,而不是 http://。WebSocket 同样支持加密的连接,这需要使用以 wss:// 为协议前缀的 TLS。默认情况下 WebSocket 使用 80 端口建立非加密的连接,使用 443 端口建立加密的连接。你可以通过给 URL 带上自定义端口来覆盖默认配置。要记住,并不是所有的端口都可以被客户端使用,一些非常规的端口很容易被防火墙拦截。
说到现在,你或许会想,“我还不能在项目中使用 WebSocket,因为标准还未成型,而且 IE 不支持 WebSocket”。这样的想法并没有错,幸运的是,我们有解决方案。Web-socket-js( https://github.com/gimite/web-socket-js )是一个基于 AdobeFlash 实现的 WebSocket。用这个库就可以在不支持 WebSocket 的浏览器中做优雅降级。毕竟几乎所有的浏览器都安装了 Flash 插件。基于 Flash 实现的 SocketAPI 和 HTML5 标准规范完全一样,因此当 WebSocket 的浏览器兼容性更好的时候,只需简单地将库移除即可,而不必对代码做任何修改。
尽管客户端的 API 非常简洁、直接,但在服务器端情况就不同了。WebSocket 协议包含两个互不兼容的草案协议:草案 75( http://goo.gl/cgSjp )和草案 76( http://goo.gl/2u78y )。服务器需要通过检测客户端使用的连接握手类型来判断使用哪个草案协议。
WebSocket 首先向服务器发起一个 HTTP“升级”(upgrade)请求。如果你的服务器支持 WebSocket,则会执行 WebSocket 握手并初始化一个连接。“升级”请求中包含了原始域(请求所发出的域名)的信息。客户端可以和任意域名建立 WebSocket 连接,只有服务器才会决定哪些客户端可以和它建立连接,常用做法是将允许连接的域名做成白名单。
在 WebSocket 的设计之初,设计者们希望只要初始连接使用了常用的端口和 HTTP 头字段,就可以和防火墙和代理软件和谐相处。然而理想是丰满的,现实是骨感的。有些代理软件对 WebSocket 的“升级”请求的头信息做了修改,打破了协议规则。事实上,协议草案的最近一次更新(版本 76)也无意中打破了对反向代理和网关的兼容性。为了更好更成功地使用 WebSocket,这里给出一些步骤:
- 使用安全的 WebSocket 连接(wss)。代理软件不会对加密的连接胡乱篡改,此外你所发送的数据都是加密后的,不容易被他人窃取。
- 在 WebSocket 服务器前面使用 TCP 负载均衡器,而不要使用 HTTP 负载均衡器,除非某个 HTTP 负载均衡器大肆宣扬自己支持 WebSocket。
- 不要假设浏览器支持 WebSocket,虽然浏览器支持 WebSocket 只是时间问题。诚然,如果连接无法快速建立,则迅速优雅降级使用 Comet 和轮询的方式来处理。
那么,如何选择服务器端的解决方案呢?幸运的是,在很多语言中都实现了对 WebSocket 的支持,比如 Ruby、Python 和 Java。要再次确认每个实现是否支持最新的 76 版协议草案,因为这个协议是被大多数客户端所支持的。
-
Node.js
─ node-Websocket-server( http://github.com/miksago/node-websocket-server )
─ Socket.IO( http://socket.io ) -
Ruby
- EventMachine( http://github.com/igrigorik/em-websocket )
- Cramp( https://github.com/lifo/cramp )
- Sunshowers( http://rainbows.rubyforge.org/sunshowers/ )
-
Python
- Twisted( http://github.com/rlotun/txWebSocket )
- Apache module( http://code.google.com/p/pywebsocket )
-
PHP
- php-Websocket( http://github.com/nicokaiser/php-websocket )
-
Java
- Jetty( http://www.eclipse.org/jetty )
-
Google Go
- native( http://code.google.com/p/go )
三、Node.js 和 Socket.IO
在上面的名单中,Node.js( http://nodejs.org )是一名新成员,也是当下最受关注的新技术。Node.js 是基于事件驱动的 JavaScript 服务器,采用了 Google 的 V8 引擎( http://code.google.com/p/v8 )。正因为此,Node.js 速度非常快,也可以解决服务器高并发连接数的资源消耗问题,和 WebSocket 服务器一样。
Socket.IO( http://socket.io/ )是一个 Node.js 库,实现了 WebSocket。最让人感兴趣的不止于此,来看一段官网上的宣传文字:
Socket.IO 的目标是在每个浏览器和移动设备中构建实时 APP,这缩小了多种传输机制之间的差异。
如果环境支持 WebSocket,那么 Socket.IO 就会尝试使用 WebSocket,若有必要也会降级使用其他的传输方式。这里列出了所支持的传输方式,非常全面,因此 WebSocket.IO 可以做到更好的浏览器兼容:
- WebSocket
- Adobe Flash Socket
- ActiveX HTMLFile (IE)
- 基于 multipart 编码发送 XHR(XHR with multipart encoding)
- 基于长轮询的 XHR
- JSONP 轮询(用于跨域的场景)
Socket.IO 的浏览器支持非常全面。“服务器推”的实现是众所周知的难题,但 Socket.IO 团队为你解决了这些烦恼,Socket.IO 保证了它能兼容大多数浏览器,浏览器支持情况如下:
- Safari >= 4
- Chrome >= 5
- IE >= 6
- iOS
- Firefox >= 3
- Opera >= 10.61
尽管在服务器端实现的 Socket.IO 最初是基于 Node.js 的,现在也有用其他语言实现的版本了,比如 Ruby(Rack)( http://github.com/markjeee/Socket.IQ-rack ),Python(Tornado)
( https://github.com/MrJoes/tornadio ),Java( http://code.google.com/p/socketio-java )和
GoogleGo( http://github.com/madari/go-socket.io )。
来看一下它的 API,写法非常简单、直接,客户端的 API 和 WebSocket 的 API 看起来很像:
<span>var</span> socket = <span>new</span> io.Socket(); socket.on(<span>"connect"</span>, <span><span>function</span><span>()</span>{</span> socket.send(<span>'hi!'</span>); }); socket.on(<span>"message"</span>, <span><span>function</span><span>(data)</span>{</span> alert(data); }); socket.on(<span>"disconnect"</span>, <span><span>function</span><span>()</span>{</span>});
在后台 Socket.IO 会选择使用最佳的传输方式。正如在 readme 文件中所描述的,“你可以使用 Socket.IO 在任何地方构建实时 APP”。
如果你想寻求比 Socket.IO 更高级的解决方案,可以关注一下 Juggernaut( http://github.com/maccman/juggernaut ),它就是基于 Socket.IO 实现的。Juggernaut 包含一个信道接口(channelinterface):客户端可以订阅信道监听,服务器端可以向信道发布消息,即所谓的订阅 / 发布( http://en.wikipedia.org/wiki/PubSub )模式。这个库可以针对不同的客户端和实现环境作灵活扩展,比如基于 TLS 等。
如果你需要虚拟主机中的解决方案,可以参考 Pusher( http://pusherapp.com/ )。Pusher 可以让你从繁杂的服务器管理事务中抽身出来,使你能将注意力集中在有意义的部分:Web 应用的开发。客户端的实现非常简单,只需将 JavaScript 文件引入页面中并订阅信道监听即可。当有消息发布的时候,仅仅是发送一个 HTTP 请求到 RESTAPI( http://pusherapp.com/docs )。
四、实时架构
将数据从服务器推送给客户端的理论看起来有点纸上谈兵,如何将理论和 JavaScript 应用的开发实践相结合呢?如果你的应用正确地划分出了模型,那么应用实时架构将会非常简单。接下来我们给出在应用中构建实时架构的每个步骤,这里大量用到了订阅 / 发布模式。首先需要了解的是将更新通知到客户端的整个过程。
实时架构是基于事件驱动的(event-driven)。事件往往是由用户交互触发的:用户修改了数据记录,事件就会传播给系统,直到数据推送给已经建立连接的客户端并更新数据。要想为你的应用构建实时架构,则需要考虑两件事:
- 哪个模型需要是实时的?
- 当模型实例发生改变时,需要通知哪些用户?
实际情况往往是当模型发生改变时,你希望给所有建立连接的客户端发送通知。这种情况更多发生在网站首页需要实时提供活动的数据源的场景中,比如,每个客户端都能看到相同的信息。然而更多的应用场景是,要想针对不同的用户群发送不同的数据源,你需要根据不同类型的数据源有针对性地给用户推送更新。
我们来看一个聊天室的场景:
1.用户在聊天室中发送了一个新消息。
2.客户端向服务器发送一条 AJAX 请求,并创建一条 Chat 记录。
3.在 Chat 模型上触发了“保存”的回调,调用我们的方法来更新客户端数据。
4.查找聊天室中所有和这个 Chat 记录有关的用户,我们需要给这些用户发送更新通知。
5.用一条更新来描述发生了什么事情(创建 Chat 记录),将这个更新推送给相关的用户。
这个过程的细节和你选用的服务器环境有关,然而,如果你使用 Rails,Holla( http://github.com/maccman/holla )是一个非常不错的例子。当创建了 Message 记录时,JuggernautObserver 会更新相关的客户端。
现在就引入了另外一个问题:如何向特定用户发送通知?最佳方法是使用发布 / 订阅模式:客户端订阅某个特定的信道,服务器向这个信道发布消息。每个用户订阅唯一的信道,信道包含一个 ID,可能是用户在数据库中存放的 ID。然后,服务器只需向这个唯一的信道发布消息即可,这样就可以做到将通知发送给特定的用户。
例如,某个用户可以订阅下面这个信道:
/observer/0765F0ED-96E6-476D-B82D-8EBDA33F4EC4
这里的随机字符串是当前登录用户唯一的标识。要想将通知发送给这个特定用户,服务器只需向同一个信道发布消息即可。
你可能很想知道发布 / 订阅模式在信息传输过程(WebSocket 或 Comet)中是怎样工作的。幸运的是,已经有很多可用的解决方案,比如 Juggernaut 和 Pusher,之前都有提到过。发布 / 订阅是最常见的抽象,处于 WebSocket 的最高层,不管你选用什么服务或库,它们的 API 都非常相似。
一旦服务器将通知推送给客户端,你将体会到 MVC 架构带来的美感。让我们回过头来看刚才的聊天室的例子。发送给客户端的通知格式看起来像这样:
{ <span>"klass"</span>:<span>"Chat"</span>, <span>"type"</span>: <span>"create"</span>, <span>"id"</span>: <span>"3"</span>, <span>"record"</span>: {<span>"body"</span>: <span>"New chat"</span>} }
它包含一个被更改的模型、更新类型和其他相关属性。使用它可以让客户端在本地创建新的 Chat 记录。由于客户端的模型已经绑定了 UI,因此用户界面会根据新的聊天记录自动更新。
最让人吃惊之处在于这个过程并不和特定的 Chat 模型相关,如果我们想创建另一个实时模型,只需添加另外一个服务器观察者,确保服务器更新时客户端会随之更新即可。现在我们的后台和客户端模型绑定在一起。任何后台模型的更改都会自动传播给相关的客户端,并更新 UI。使用这种架构搭建的应用就是真正的实时应用。一个用户和应用产生的任何交互即刻被广播给其他的用户。
五、感知速度
速度是 UI 设计最重要也是最易忽略的问题,速度对用户体验(UX)的影响非常大,并直接影响网站的收益。很多大公司一直都在研究、调查速度和网站收益之间的关系:
- Amazon
页面加载时间每增加 100 毫秒,就会造成 1% 的销售损失(来源:GregLinden,Amazon)。 - Google
页面加载时间每增加 500 毫秒,就会造成 20% 的流量损失(来源:Marrissa Mayer,Google)。 - Yahoo!
页面加载时间每增加 400 毫秒,在页面加载完成之前就单击“后退”按钮的人会增加 5%~9%(来源:Nicole Sullivan, Yahoo!)。
“感知速度”(perceived speed)和真实的速度同等重要,因为感知速度关系到用户的感官体验。因此,关键是要让用户“感觉”到你的应用很快,尽管实际的速度可能并不快,而这正是 JavaScript 应用带给我们的最大好处:尽管某一时刻在后台会有很多请求不会及时响应,但 UI 不会被阻塞。
让我们再次回过头来讨论刚才聊天室的场景。用户发送了新的消息,触发了一个 AJAX 请求。我们可以等待这个请求在网络中走一个来回之后,将响应结果更新到聊天记录中。然而,从发起请求的时刻开始,到获得响应并更新至聊天记录,会有几秒钟的延时。这会让应用看起来很慢,肯定会造成用户体验上的损失。
既然如此,为什么不直接在本地创建一个新记录呢?只需将消息立即添加至聊天记录中即可。用户会感知到这个消息被立即发送出去了,他们不知道(甚至不关心)这个消息是否被分发给了聊天室中的所有人。只有这种清澈、流畅的产品体验,才会让用户倍感愉悦。
除了交互设计的小窍门之外,Web 应用中最耗时的部分是新数据的加载。最明智的做法是在用户请求数据之前预测用户的行为并预加载数据,这一点非常重要。预加载的数据被缓存在内存中,如果随后用户需要这个数据,就不必再发起到服务器的请求了。应用在启动伊始就应当预加载常用的数据。应用加载时的略微延时或许可忍,而加载完成后糟糕的交互体验断不可忍。
当用户和你的应用产生交互时,你需要适时给用户一些反馈,通常使用一些可视化的进
度指示来给出反馈。用行业术语来讲就是“期望管理”(expectationmanagment)——要让用户知道当前项目的状态和估计完成时间。“期望管理”同样适用于用户体验领域,适时地给用户一些反馈,告知用户发生了什么事情,会让用户更有耐心等待程序的运行。当用户等待新数据的加载时最好给出信息提示或一张旋转的小图片。如果在上传文件,则给出上传进度条及估计完成时间。这些都属于感知速度的范畴,可有效地提升产品的用户体验。
书籍介绍
在琳琅满目的 Web 富客户端应用实现方式中,JavaScript 在其中巧妙地穿针引线,扮演着”黏合剂”的作用。JavaScript 与各种浏览器插件技术(Silverlight、ActiveX、Flash、Applet)均拥有互操作能力,无论这种插件技术是主流的、还是生僻的,是传统的、还是现代的。JavaScript 是唯一不需安装任何插件,便被各大主流 Web 浏览器支持的动态脚本,可谓拥有天然的跨平台性。未来之 RIA,必是以 JavaScript 为核心!
活动推荐:
2023年9月3-5日,「QCon全球软件开发大会·北京站」 将在北京•富力万丽酒店举办。此次大会以「启航·AIGC软件工程变革」为主题,策划了大前端融合提效、大模型应用落地、面向 AI 的存储、AIGC 浪潮下的研发效能提升、LLMOps、异构算力、微服务架构治理、业务安全技术、构建未来软件的编程语言、FinOps 等近30个精彩专题。咨询购票可联系票务经理 18514549229(微信同手机号)。
评论