当今的 Web 应用在我们的个人生活与商业应用中的各个方面已经表现出愈发重要的作用。这些应用包括社交媒体网络、在线购物、商业应用,乃至家用电器的配置程序。虽然它的增长势头依然迅猛,但 Web 应用的用户体验与原生应用或桌面应用相比仍然相形见绌,其主要原因是 Web 应用的设计依赖于单向的 HTTP 协议。而 WebSocket 将改变这一现状,它为浏览器与服务端的交互带来了一种新的基础元素,为创建一种能够提供真正的交互性体验的 Web 应用提供了必要的基础。
早期的 Web 技术都是基于 HTTP 协议而发展起来的,而 HTTP 只是一个简单的基于请求 —— 响应操作的协议,所有的请求都是由客户端发起的。这套框架原本足以满足用户的需求,但在如今开发者所设计的 web 应用中,由客户端发起通信这种方式有着很大的制约。虽然人们提出了各种临时方案,但它们都是基于 HTTP 协议的,只是应用了轮询或长轮询技术(例如 Comet)。Comet 能够让负责处理请求的线程得到释放,以防止服务器资源耗尽。由于轮询这种机制并不可靠,因此在 2007 年时,有人提出了一种名为 WebSocket 的全双工(full-duplex)类型的通信方式。这项提议用了整整 4 年的时间才成为一个标准。但是,尽管它已成为一种标准,但它的使用率却相当有限。本文将为读者解释妨碍 WebSocket 应用的两大原因,并且提出了一个设计框架,开发者可以使用这套框架快速地发挥 WebSocket 的潜能,并且极大地丰富应用的体验。
导致 WebSocket 使用率低下的第一个原因在于应用服务器与浏览器对其支持不足。但随着新一代应用服务器与浏览器的出现,这种状况得到了很大的改善。而第二个原因比起前一点来说其影响更大,亦即要充分利用 WebSocket 的全部潜能,必须对 Web 应用进行颠覆性的重新设计。而这个重新设计过程需要将基础的请求 —— 响应这一结构转变为更复杂的双向消息传递结构。应用程序的重新设计往往是一个开销很大的过程,而软件供应商很难从这一过程中看到任何显著的利益。
我们首先将对 WebSocket 做个简单的介绍,随后展示一种使用 WebSocket 重新构建应用程序的方法,最后通过一个简单的示例表现这一方法的各种要点。
WebSocket 简介
WebSocket 是在 TCP/IP 协议之上创建的一种帧协议,客户端将通过向服务器发送一种特殊的 HTTP 请求以启动 WebSocket。在最初的握手过程之后,客户端与服务器就能够自由地以异步方式互相进行帧的传送了。帧分为两种类型:即控制帧与数据帧。最小的控制帧仅有 2 比特的大小,而在数据帧方面,客户端的数据帧最小为 6 比特,服务端的数据帧最小为 2 比特。数据帧既可以是文本型,也可以是二进制的。文本帧都经过了 UTF-8 的编码。帧可以实现分块,因此一个大数据集可以分解为多个帧。WebSocket 不会为帧附加任何标识信息,因此不同类型的信息对应的帧不可混用。只有控制帧能够在处理一个大消息时的一系列中间帧中出现。在这些基础的帧之上,还可以定义更复杂的协议。比方说,一个帧能够带有校验和或是它的序列号等相关信息。
WebSocket 的 API
WebSocket 并不限定于仅在某个特定的编程语言、系统或是操作系统中使用。多数主流的编程语言以及许多浏览器都已开始支持 WebSocket 的编程。虽然在不同的平台与编程语言中存在着大量的标准,但本文仅关注 JavaScript HTML5 以及 Java(J2EE)对 WebSocket 的支持。在浏览器这方面有两种实现标准,其最新版本分别为 Hixie-76 和 HyBi-17(不久之后发展为 IETF RFC 6455)。HyBi 的实现相对更高级,并且得到了目前所有主流浏览器的支持。而在服务端方面,基于 Java 的实现则是目前最为流行的。早些时候在 Java 上曾经出现过几种 WebSocket 的实现,它们之后已发展为 JSR 356 这种实现。JSR 代表 Java 规范请求,对规范请求的说明有帮于让之后的各种实现保持一致性,并且易于使用。JSR 也让开发者不必依赖于某个特定的实现。JSR 356 与 servlet 规范是相互分离的,但它也允许开发者访问某些 servlet 对象。JSR 356 的内容涵盖了 WebSocket 连接的客户端与服务端, 我们稍后的讨论将集中于配合浏览器端的 JavaScript 所实现的服务端。JSR 356 目前属于 J2EE 7 的一部分,所有流行的开源 Java 应用服务器都支持它,包括 Tomcat、Jetty、Glassfish 以及 TJWS 等等。除此之外,在 Java 环境中还存在着大约 20 种各自独立的 WebSocket 服务端解决方案,其中有些方案也支持 JSR 356。由于 WebSocket 是 J2EE 7 的一部分,因而在由 Oracle 与 IBM 所推出的商业应用服务器上同样也得到支持。
正如我之前所说,WebSocket 是一种消息传递协议。它的 API 提供了各种在通信双方进行消息传递与接收的方法。这里并不存在经典的订阅者与发布者的关系。消息只有两种类型,即文本型与二进制型。不过,在这些类型的消息处理函数中可以对消息进行逻辑上的分离。在 Java 中能够以某种方式处理被分解为多个块的部分消息,而 JavaScript 尚未支持这种程度的控制能力。如同之前所说,WebSocket 是一种非常泛用的协议,它可以在握手时指定所需的逻辑子协议。当不同的系统能够验证所连到的系统支持这种逻辑子协议及扩展时,使用 WebSocket 进行系统集成就变得容易很多。WebSocket 帧格式允许在它的基础上使用可协商的扩展,这与意味着一般来说帧可能会提供更多的信息,并且可能会引入不同的帧类型。
浏览器端的 JavaScript 实现
由于 WebSocket 协议的握手过程是由客户端发起的,因此需要通过包含了 WebSocket 接口的 JavaScript 代码对所有 WebSocket 操作进行封装。
该接口已经实现了标准化 1,并通过接口定义语言(IDL)进行定义,如以下代码所示:
[Constructor(in DOMString url, in optional DOMString protocols)] [Constructor(in DOMString url, in optional DOMString[] protocols)] interface WebSocket { readonly attribute DOMString url; // ready state const unsigned short CONNECTING = 0; const unsigned short OPEN = 1; const unsigned short CLOSING = 2; const unsigned short CLOSED = 3; readonly attribute unsigned short readyState; readonly attribute unsigned long bufferedAmount; // networking attribute Function onopen; attribute Function onmessage; attribute Function onerror; attribute Function onclose; readonly attribute DOMString protocol; void send(in DOMString data); void close(); }; WebSocket implements EventTarget;
WebSocket 的构建函数包含两个参数:
- WebSocket 的 URL
- 必要的子协议的数组或单个元素,这一参数是可选的
WebSocket 的 URL 都是以“ws”为前两个字符,它代表所使用的是 WebSocket 协议,而其余部分与 HTTP 协议中的 URL 相同,包括主机、端口、路径以及查询字符串。如果需要使用安全连接,可以在协议名称上加一个额外的“s”字符。
可以指定的消息处理函数共有四种:onopen、onmessage、onclose 和 onerror。在传递消息时需要调用 _send_ 方法,而在关闭连接时则需要调用 _close_ 方法。由于不存在类似于 _connect_ 这样的方法,因此客户端必须监听 onopen 消息,以确认连接已建立,随后才能够进行 _send_ 操作。另一种选择是对 WebSocket 对象的 readyState 属性进行轮询,但这种方式并不推荐使用。显然,在 onmessage 处理函数中总是能够调用 send 操作的。send 操作由客户端异步执行,这也意味着 JavaScript 在将消息传递给接收者的过程中无须等待其结果,而是直接返回。文本消息或二进制消息在接收时不存在任何差别,因此在 onmessage 处理函数中必须对事件的 data 参数进行检查。WebSocket 提供了一些属性,可用于获取状态、判断二进制消息的格式等目的。而其它浏览器厂商的特定实现中还可以包含更多的属性,因此请记得仔细阅读浏览器的文档,以了解详细的信息。
Java 端的 WebSocket 实现
Java 中的 JSR 356 定义了常见的(客户端)与服务端的 Java WebSocket 通信 API。在 Java 的实现中会指定终结点与服务端终结点对象,这与 JavaScript 中的 WebSocket 实现颇为类似。可以通过注解的方式将某个 Java 类指定为一个终结点对象,而通过 OnOpen、OnMessage、OnError 和 OnClose 等注解信息指定事件处理函数。在每种类型的处理函数中,都可以将重要的 Session 对象作为一个传入参数。Session 对象让开发者能够访问发送消息的功能,并且能够保持与 WebSocket 连接相关的状态特性。消息的发送可以使用同步或异步机制,并且在两种类型的发送机制中都可以指定超时时间。通过指定相应的解码器,二进制与文本数据都能够自动转换为任意的 Java 对象,而编码器则允许 WebSocket 发送任意类型的 Java 对象。对于某个特定的 WebSocket URL 路径,消息处理函数只能对应文本消息类型或二进制消息类型的其中一种。Java 中未提供消息链的功能,但也可以通过编程的方式对其进行组织。Java 端的 API 很容易上手,它提供了一种可自定义的配置对象,能够影响最初的握手过程,决定所支持的子协议、版本,并且提供访问重要的 servlet 对象 API 的功能。终结点不仅能够通过注解的方式进行部署,也能够通过编程的方式所生成。
对 Web 应用的重新思考
WebSocket 对于以下类型的应用程序的开发是一种非常自然的选择:
- 需要玩家之间实时协作的游戏
- 实时监控系统
- 需要用户进行协作的系统,例如聊天、共享文档的编辑等等。
其实,WebSocket 在传统的 Web 应用中也能够展现其优势。大多数 Web 应用都是基于请求 - 响应这一范式进行设计的。虽然 AJAX 能够实现异步操作,但在继续处理下一步操作之间,仍然必须等待响应返回。而由于 WebSocket 连接只需建立一次,从而避免了为每次数据交换重建连接的过程,并且在后续的通信中也无需发送多余的 HTTP 头信息。这种优势在 SSL 类型的连接上体现得尤为明显,因为最初的连接握手是一个开销很大的操作。浏览器端的 WebSocket 发送操作是完全异步的,而 Java 的服务端代码在发送消息后无需进行等待。由于发送消息的这种自由度,在应用中或许需要对某些操作进行手动记录,以保持应用状态的一致性。在使用 WebSocket 时也能够模拟请求 - 响应这一范式,但如此一来,WebSocket 作为一种真正的异步双向消息传递系统的优势也被大大消减了。由于以上所描述的这些特性,因此应当鼓励开发者在某些场景中对应用程序的设计方式进行重新思考。
假设某一个应用程序包含了复杂的用户界面,其中某些区域的功能需要通过服务端的大量计算才能够生成对应的内容。传统的基于 AJAX 的实现方式可以选择一种延迟调用的机制,通过某个内容请求调用以生成这一区域的内容。而在使用 WebSocket 的场合下,服务端可以在浏览器做好准备的情况下直接发送内容,而无需对某个 AJAX 请求进行响应。AJAX 请求这一方式的缺陷在于,由于浏览器所发送的请求是串行的,因此服务端的处理过程无法针对请求的顺序进行相应的优化。而 WebSocket 为服务端提供了一个自行决定最佳的内容生成方式的机会,因而能够提升 Web 应用的整体响应性。
要用效地利用 WebSocket 的功能,还需要仔细考虑几个额外的要点。由于在 WebSocket 中随时可能出现网络连接的丢失,使数据无法正确地传递,因此对于一些至关重要的数据需要进行一些额外的手动记录操作。一般来说,所收到的每条消息都必须提供足够的信息,以指示如何对其进行处理。但没有有效的手段能够了解信息的请求者是谁,是来自客户端的请求,还是说服务端想要更新某些内容。在具体使用 WebSocket 的过程中,可能需要对 Web 应用的设计进行更深入的重新思考。此外,JavaScript 代码的功能可以迁移至服务端,打个比方,用户的输入可以立即发送给服务端进行处理,通过这种方式能够实现一些复杂的数据校验操作,而这些校验功能或许是 JavaScript 所无法处理的。用户的输入还能够即时地保存在后台系统中,因此浏览器就无需将最终的数据传递给服务器进行额外的数据校验,因为数据在保存在后台期间已经经过了校验。如果要使某个应用从富 Web 客户端转为一种轻量级的客户端,就可以考虑以这种方式增加服务端代码的职责。
使用 WebSocket 时所需注意的要点
在 Web 应用开发时使用 WebSocket 也会面对一些特别的挑战,WebSocket 的 Session 与 HTTP 的 Session 之间并无任何关联,虽然也可将其用作类似的目标。在 Session 中可以附加某些通用的数据,因此所有的消息处理过程都可以依赖于 Session 中所维护的某些状态和数据。WebSocket 的 Session 也可以根据空闲(不活跃)时间间隔的配置产生超时情况,正如 HTTP Session 一样。不过有些系统会自动地持续发送 Ping 这一控制消息,以防止出现超时。JSR 356 建议将 HTTP Session 与 WebSocket Session 的超时进行同步。一旦 HTTP Session 超时,在其范围内所创建的所有 WebSocket 连接也都必须关闭。但有些 Web 应用的设计不会产生任何 HTTP Session,而有些应用的 Session 超时不依赖于 HTTP Session,而是由 JavaScript 所管理的,因此这种机制并不能够进行可靠的推广。
另一种需要注意的要点在于,某些浏览器会维护一个连接池,以重用连接的方式访问相同的网站,因此这种流程可以被串行化。而如果浏览器为 WebSocket 连接也创建一个连接池,那么它会受到严重的制约。因为如果没有某种机制保持 WebSocket 连接的关闭,这个连接就永远处于活跃状态,而其它任何创建新连接的尝试都会产生死锁。因此,最佳实践的推荐做法是只使用一个 WebSocket 连接。
浏览器无法对通过 WebSocket 进行传递的数据进行缓存,因此通过 WebSocket 传递可以在浏览器中缓存的资源
(例如图片、CSS 等)并非一种有效的途径。
WebSocket 与 RESTful 的比较
在网络上对于 RESTful 与 WebSocket 之间的讨论从未停歇 2。不过,这些比较中的大部分都不是在一个层面上的比较,好比关公战秦琼。REST 是指表述性状态转换,多数情况下它需要依赖底层的 HTTP 协议实现,也就是说 REST 是一个基于请求 - 响应的协议。REST 这种风格没有经过标准化,因此任何一种通过 HTTP 进行通信的方式在某些范围内都可以称为 REST。REST 通常会将新增、读取、更新和删除操作(CRUD)与对应的 HTTP 方法 PUT、GET、DELETE 之间建立映射关系。而 WebSocket 所处理的是消息,因此对于单一的 RPC 来说不存在一个确定的范围。REST 的通信数据格式通常仅限 JSON 格式以及请求参数,而一个 WebSocket 消息体可以表现为任何类型,包括纯粹的二进制数据 3。
当然,WebSocket 也能够用于与 REST 相似的目的,但在大多数情况下,这种做法有些刻意为之了。正如上文所述,在使用 WebSocket 过程中需要应用一些不同的设计原则。下表描述了这两者之间的主要区别 4。
文件上传的示例
以下示例展现了如何通过使用 WebSocket 将一个文件上传至服务器,首先最好定义一个服务端的终结点。
@ServerEndpoint("/upload/{file}") public class UploadServer {
其中要定义两个消息处理函数,一个用于接收上传文件的二进制数据,而另一个则用于命令接口。由于在 WebSocket 中允许分离文本与二进制消息,因此在定义两个处理函数时无需进行额外的操作。用于接收命令的 OnMessage 处理函数定义如下:
@OnMessage public void processCmd(CMD cmd, Session ses) {
CMD 类的定义如下
static class CMD { public int cmd; public String data; }
为了将文本消息转换为 CMD 对象,需要指定一个解码器,其定义如下:
public static class CmdDecoder implements Decoder.Text<CMD> {
将文本信息编码为 JSON 格式并不是一种强制性的要求,只是在这个示例中需要用到 JSON。大文件的上传是分多个块进行的,以减少内存的开销。在浏览器中无法利用 WebSocket 的部分帧,因此需要用到完整的帧来模拟块传送。由于浏览器以异步的方式发送所有的消息,因此无法得知服务端是否已经接收到了一个完整的文件。命令接口的作用是完成以下工作:
- 通知服务器上传即将开始,并且为上传文件设定一个名称
- 通知服务器已上传了一个完整的文件
- 向客户端发送确认,表示文件已成功地保存了
同样的 CMD 对象可以进行重用,以满足各种需求。传入的命令是按照以下方式进行处理的:
@OnMessage public void processCmd(CMD cmd, Session ses) { switch (cmd.cmd) { case 1: // start fileName = cmd.data; break; case 2: // finish close(ses); cmd.cmd = 3; ses.getAsyncRemote().sendObject(cmd); break; } }
这种实现方式假设浏览器端会将所有发送消息的活动进行串行化,即所有消息的到达顺序与发送顺序是一致的。但是如果某个客户端使用了某些并行方式进行发送,那么就需要一种更为复杂的实现方式,让每个所发送的消息都带有一个 ID。另一种方案是为每个收到的文件块都发送一次确认消息,只是这样一来 WebSocket 的优势也就丧失殆尽了。由于 CMD 对象的目标是将消息发送至客户端,因此必须提供一个编码器:
public static class CmdEncoder implements Encoder.Text<CMD> {
在 ServerEndpoint 的注解中必须指定解码器与编码器信息,如下所示:
@ServerEndpoint(value = "/upload/{file}", decoders = UploadServer.CmdDecoder.class, encoders=UploadServer.CmdEncoder.class) public class UploadServer {
二进制消息的处理函数定义如下:
@OnMessage public void savePart(byte[] part, Session ses) { if (uploadFile == null) { if (fileName != null) try { uploadFile = new RandomAccessFile(fileName, "rw"); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); return; } } if (uploadFile != null) try { uploadFile.write(part); System.err.printf("Stored part of %db%n", part.length); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } }
此外还可以为 OnClose 事件加入一个处理函数,万一出现连接异常关闭的情况,它将负责删除不完整的文件。
客户端的实现利用了 HTML5 中的工作线程(Worker)功能,不幸的是,Firefox 没有采用在 Worker 中实现文件对象克隆的方式,因此这个示例只能在 IE 或 Chrome 中进行测试。如果该解决方案对于浏览器的可移植性有很高的要求,那么可以用一个不使用 Worker 的 JavaScript 代码段来代替这个基于 Worker 的解决方案。但由于未使用独立的线程(即 Worker),因此这种方案的性能会有所下降。Worker 的代码如下所示:
var files = []; var endPoint = "ws" + (self.location.protocol == "https:" ? "s" : "") + "://" + self.location.hostname + (self.location.port ? ":" + self.location.port : "") + "/echoserver/upload/*"; var socket; var ready; function upload(blobOrFile) { if (ready) socket.send(blobOrFile); } function openSocket() { socket = new WebSocket(endPoint); socket.onmessage = function(event) { self.postMessage(JSON.parse(event.data)); }; socket.onclose = function(event) { ready = false; }; socket.onopen = function() { ready = true; process(); }; } function process() { while (files.length > 0) { var blob = files.shift(); socket.send(JSON.stringify({ "cmd" : 1, "data" : blob.name })); const BYTES_PER_CHUNK = 1024 * 1024 * 2; // 1MB chunk sizes. const SIZE = blob.size; var start = 0; var end = BYTES_PER_CHUNK; while (start < SIZE) { if ('mozSlice' in blob) { var chunk = blob.mozSlice(start, end); } else if ('slice' in blob) { var chunk = blob.slice(start, end); } else { var chunk = blob.webkitSlice(start, end); } upload(chunk); start = end; end = start + BYTES_PER_CHUNK; } socket.send(JSON.stringify({ "cmd" : 2, "data" : blob.name })); //self.postMessage(blob.name + " Uploaded Succesfully"); } } self.onmessage = function(e) { for (var j = 0; j < e.data.files.length; j++) files.push(e.data.files[j]); //self.postMessage("Job size: "+files.length); if (ready) { process(); } else openSocket(); }
很方便的一点在于,与 Worker 进行交互的 JavaScript 代码也能够利用消息传递机制。当用户在浏览器中选择文件进行上传时,这一操作的信息就会传递给 Worker。后者会以批量的方式处理第一个准备上传的文件,它将文件分成多个片段,即多个块,然后通过 WebSocket 将这些块依次上传。最后发送一个 cmd = 2 的命令消息。而命令消息的处理函数会将消息重新发送给主 JavaScript 代码,通知所上传的文件已经完成了。如果客户端选择上传许多大文件,那么这段代码会对浏览器端带来相当大的压力。为此需要对代码进行重新调整,让它在收到上一个文件上传成功的消息后才继续上传下一个文件。这部分内容的修改就留给各位读者作为一个练习吧。在附录 1 中可以找到本示例的完整源代码。
参考
- W3C 候选推荐 2012 年 9 月 20 日
- REST vs WebSocket
- WebSockets versus REST?
- REST vs WebSocket Comparison and Benchmarks
附录
可以在此处找到本文中的示例、以及其它相关示例的代码。
关于作者
Dmitriy Rogatkin是 MetricStream 公司 Labs 部门的领导,致力于让 GRC 的技术研究与应用面实现真正的普及。他在早些时候为公司打造了 GRC 应用平台。同时,他是硅谷的几家创业公司的 CTO。他喜欢通过创建开源软件的方式以检验不同的想法,这些软件包括多媒体桌面应用、移动应用、乃至框架及应用服务器。在他的诸多项目中, TJWS 是一个微型应用服务器,如果完整的 Java EE Profile 应用服务器耗费太高,则可以将 TJWS 作为一种选择。Dmitriy 在闲暇时喜欢听一些高保真的音乐,作为这项爱好的一部分,他还创建了 DSD 音乐播放器这个开源软件,并且在将其稍做包装后创建了一个 Android 平台上的应用,为其取名为 Kamerton。
查看英文原文: WebSocket: Bringing Desktop Agility to Web Application
评论