WebSocket——为Web应用带来桌面应用般的灵活性【转载+整理】
本文内容
- WebSocket 简介
- 浏览器端的 JavaScript 实现
- Java 端的 WebSocket 实现
- 对 Web 应用的重新思考
- 使用WebSocket时所需注意的要点
- WebSocket与RESTful的比较
- 文件上传的示例
- 实现 Websocket 的浏览器
- 实现 Websocket 协议服务器端项目
- 参考资料
即时通讯,在 Web 早先开发就有,那时最常见的实现手段是轮询(polling)。轮询是在某个时间间隔(如1秒),由浏览器向服务器发出 HTTP request,然后服务器返回最新的数据给客户端的浏览器。这种方式最明显的缺点,是浏览器需要不断的向服务器发出请求,即便都没有消息了,而且,HTTP request 的 header 很长,实际有用数据可能只是一个很小的值,无形中占用带宽。之后,出现了 Comet,但这种技术一定程度上只是模拟全双工通信,效率较低,并需要服务器有较好的支持。
(数据通信中,数据在线路上的传送方式可以分为单工通信、半双工通信和全双工通信三种,具体不解释,比如,遥控器是单工的,对讲机是半双工的,电话是全双工的。)
WebSocket protocol 是 HTML5 一种新的协议。它实现了浏览器与服务器全双工通信(full-duplex),能更好的节省服务器资源和带宽并达到实时通讯。浏览器通过 Http 仅能实现单向通信,轮询也好,comet 也罢,都不是很理想;flash 中的 socket 和 xmlsocket 可以实现真正的双向通信。通过 flex ajax bridge,可以在 Javascript 中使用这两项功能。如果 Websocket 一旦在浏览器中得到实现,将取代上面两项技术,得到广泛的使用。
Websocket Demo
早期的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 |
REST |
已实现标准化 |
得到广泛支持 |
异步消息传递 |
(同步)请求/响应 |
基于帧 |
基于HTTP方法(get,put,delete,post) |
子协议 |
可发现的操作 |
二进制与文本 |
目前大都为 JSON 数据 |
并行双向更新 |
目前大都为 CRUD 操作(Create、Retrieve、Update 和 Delete) |
文件上传的示例
以下示例展现了如何通过使用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中可以找到本示例的完整源代码。
实现 Websocket 的浏览器
Chrome | Supported in version 4+ |
Firefox | Supported in version 4+ |
Internet Explorer | Supported in version 10+ |
Opera | Supported in version 10+ |
Safari | Supported in version 5+ |
实现 Websocket 协议服务器端项目
jetty | 7.0.1 包含了一个初步的实现 |
resin | |
pywebsocket | apache http server 扩展 |
apache tomcat | 7.0.27 版本 |
Nginx | 1.3.13 版本 |
jWebSocket | java实现版 |
参考资料
- W3C候选推荐 2012年9月20日
- REST vs WebSocket
- WebSockets versus REST?
- REST vs WebSocket Comparison and Benchmarks