反向 Ajax,第 二 部分: WebSockets
简介
现在,用户期望可以从 Web 访问快速、动态的应用程序。本 系列 文章展示了如何使用反向 Ajax 技术开发事件驱动的 Web 应用程序。反向 Ajax,第 1 部分:Comet 简介 介绍了反向 Ajax、轮询、流、Comet 和长轮询。您应该已经了解到,使用 HTTP 长轮询的 Comet 是可靠地实现反向 Ajax 的最佳方式,因为现在所有浏览器都提供了这方面的支持。
通过本文,您将学习如何使用 WebSockets 实现反向 Ajax。代码示例有助于说明 WebSockets、FlashSockets、服务器端的制约因素、请求作用域服务和暂停长期请求。您可以下载本文使用的 源代码。
先决条件
在理想的情况下,如果想最大限度地利用本文,您应该了解 JavaScript 和 Java。本文创建的示例是使用 Google Guice 构建的,Google Guice 是用 Java 编写的依赖项注入框架。要理解本文内容,则需要熟悉依赖项注入框架的概念,比如 Guice、Spring 或 Pico。
要运行本文中的示例,还需要使用最新版的 Maven 和 JDK(参阅 参考资料)。
回页首
WebSockets
WebSockets 在 HTML5 中出现,是比 Comet 更新的反向 Ajax 技术。WebSockets 支持双向、全双工通信信道,而且许多浏览器(Firefox、Google Chrome 和 Safari)也支持它。连接通过 HTTP 请求(也称为 WebSockets 握手)和一些特殊的标头 (header)。连接一直处于激活状态,您可以用 JavaScript 编写和接收数据,正如您使用原始 TCP 套接字一样。
通过输入 ws://
或 wss://
(在 SSL 上)启动 WebSocket URL。
图 1 中的时间轴展示了如何使用 WebSockets 进行通信。HTTP 握手被发送到带有特定标头的服务器。然后,可在 JavaScript 的服务器或客户端上提供某种类型的套接字。可使用该套接字来通过事件处理器异步接收数据。
图 1. 通过 WebSockets 执行反向 Ajax
在本文的 下载源代码 中有一个 WebSocket 示例。当您运行该示例时,就会到类似于 清单 1 的输出。它显示了事件如何发生在服务器端上,并立即出现在客户端。当客户端发送一些数据时,服务器将其反映在客户端上。
清单 1. 用 JavaScript 编写的 WebSocket 示例
[client] WebSocket connection opened [server] 1 events [event] ClientID = 0 [server] 1 events [event] At Fri Jun 17 21:12:01 EDT 2011 [server] 1 events [event] From 0 : qqq [server] 1 events [event] At Fri Jun 17 21:12:05 EDT 2011 [server] 1 events [event] From 0 : vv
通常,在 JavaScript 中使用 WebSockets 的方式与 清单 2 中展示的相同(如果您的浏览器支持它)。
清单 2. JavaScript 客户端代码
var ws = new WebSocket('ws://127.0.0.1:8080/async'); ws.onopen = function() { // called when connection is opened }; ws.onerror = function(e) { // called in case of error, when connection is broken in example }; ws.onclose = function() { // called when connexion is closed }; ws.onmessage = function(msg) { // called when the server sends a message to the client. // msg.data contains the message. }; // Here is how to send some data to the server ws.send('some data'); // To close the socket: ws.close();
可以发送和接收任何类型的数据。WebSockets 可被看作是 TCP 套接字,因此由客户端和服务器决定要发送的数据类型。这里给出的示例发送的是 JSON 字符串。
在创建了 JavaScript WebSocket 对象后,如果在浏览器的控制台(或 Firebug)深入查看 HTTP 请求中的握手,您应该看到特定于 WebSocket 的包头。清单 3 展示了一个示例。
清单 3. HTTP 请求和响应标头示例
Request URL:ws://127.0.0.1:8080/async Request Method:GET Status Code:101 WebSocket Protocol Handshake Request Headers Connection:Upgrade Host:127.0.0.1:8080 Origin:http://localhost:8080 Sec-WebSocket-Key1:1 &1~ 33188Yd]r8dp W75q Sec-WebSocket-Key2:1 7; 229 *043M 8 Upgrade:WebSocket (Key3):B4:BB:20:37:45:3F:BC:C7 Response Headers Connection:Upgrade Sec-WebSocket-Location:ws://127.0.0.1:8080/async Sec-WebSocket-Origin:http://localhost:8080 Upgrade:WebSocket (Challenge Response):AC:23:A5:7E:5D:E5:04:6A:B5:F8:CC:E7:AB:6D:1A:39
所有标头都被 WebSocket 握手用来授权和建立长期连接。WebSocket JavaScript 对象还包含两个有用的属性:
ws.url
ws.readyState
- CONNECTING = 0
- OPEN = 1
- CLOSED = 2
在服务器端,处理 WebSockets 时更加复杂。还没有 Java 规范提供支持 WebSockets 的标准方式。要使用 Web 容器(如 Tomcat 或 Jetty)的 WebSockets 功能,则需要将应用程序代码紧密聚集到使您能够访问 WebSockets 功能的特定于容器的库中。
示例代码 的 websocket 文件夹中的示例使用了 Jetty 的 WebSocket API,因为我们使用的是 Jetty 容器。清单 4 展示了 WebSocket 处理器。(本系列的第 3 部分将使用不同的后端 WebSocket API。)
清单 4. Jetty 容器的 WebSocket 处理器
public final class ReverseAjaxServlet extends WebSocketServlet { @Override protected WebSocket doWebSocketConnect(HttpServletRequest request, String protocol) { return [...] } }
使用 Jetty 时,有许多处理 WebSocket 握手的方法。更简单的方法是为 Jetty 的 WebSocketServlet
创建子类并实现 doWebSocketConnect
方法。该方法要求您返回 Jetty 的 WebSocket 接口的一个实例。您需要实现该接口,并返回某种代表 WebSocket 连接的端点。清单 5 提供了一个示例。
清单 5. WebSocket 实现示例
class Endpoint implements WebSocket { Outbound outbound; @Override public void onConnect(Outbound outbound) { this.outbound = outbound; } @Override public void onMessage(byte opcode, String data) { // called when a message is received // you usually use this method } @Override public void onFragment(boolean more, byte opcode, byte[] data, int offset, int length) { // when a fragment is completed, onMessage is called. // Usually leave this method empty. } @Override public void onMessage(byte opcode, byte[] data, int offset, int length) { onMessage(opcode, new String(data, offset, length)); } @Override public void onDisconnect() { outbound = null; } }
要向客户端发送消息,只需将该消息写入出站即可,如 清单 6 中所示:
清单 6. 向客户端发送一条消息
if (outbound != null && outbound.isOpen()) { outbound.sendMessage('Hello World !'); }
要断开客户端并关闭 WebSocket 连接,可以使用 outbound.disconnect();
。
WebSockets 是一种非常强大的实现双向通信的方法,且无延迟,Firefox、Google Chrome、Opera 及其他现代浏览器都支持它。根据 jWebSocket 网站上的调查:
- Chrome 自 4.0.249 开始便包含本机 WebSockets。
- Safari 5.x 包括本机 WebSockets。
- Firefox 3.7a6 和 4.0b1+ 包含本机 WebSockets。
- Opera 从 10.7.9067 开始便包含本机 WebSockets。
如需关于 jWebSocket 的更多信息,请参阅 参考资料。
优势
WebSockets 提供强大的、双向、低延迟和易于处理的错误。它没有很多连接,比如:Comet 长轮询,而且也没有 Comet 流的缺点。与 Comet 相比,该 API 易于直接使用,无需使用任何其他层,Comet 需要一个很好的库来处理连接、超时、Ajax 请求、确认以及不同的传输(Ajax 长轮询和 jsonp 轮询)。
缺点
WebSockets 的缺点包括:
- 它是来自 HTML5 的新规范,并不是所有浏览器都支持它。
- 无请求作用域。由于 WebSockets 是一个 TCP 套接字,而不是一个 HTTP 请求,因此无法轻松使用请求作用域服务,如 Hibernate 的
SessionInViewFilter
。Hibernate 是一个持久性框架,提供了一个过滤器来处理 HTTP 请求。请求开始时,将建立一个绑定到请求线程的争用(包含事务和 JDBC 连接)。请求结束后,过滤器会破坏该争用。
回页首
FlashSockets
对于不支持 WebSockets 的浏览器,一些库能够回退到 FlashSockets(通过 Flash 的套接字)。这些库通常提供相同的官方 WebSocket API,但是它们通过将调用委派给网站上包含的隐藏的 Flash 组件来实现。
优势
FlashSockets 透明地提供 WebSockets 功能,即使在不支持 HTML5 WebSockets 的浏览器上也是如此。
缺点
FlashSockets 具有以下缺点:
- 它需要安装 Flash 插件(通常所有浏览器都有该插件)。
- 它要求打开防火墙的 843 端口,以便 Flash 组件能够执行 HTTP 请求来检索包含域授权的策略文件。
如果无法访问 843 端口,那么库应回退或给出一个错误。所有处理都需要一定的时间(最多 3 秒钟,具体取决于库),这会减慢网站速度。
- 如果客户端在代理服务器后面,那么到 843 端口的连接可能遭到拒绝。
WebSocketJS 项目提供一个网桥。它要求至少提供 Flash 10,并为 Firefox 3、Internet Explorer 8 和 Internet Explorer 9 提供 WebSockets 支持。
建议
与 Comet 相比,WebSockets 带来很多好处。在日常开发过程中,支持 WebSockets 的客户端的速度变得更快,产生的需求也更少(因此,使用的带宽也更少)。但是,由于并非所有浏览器都支持 WebSockets,因此支持反向 Ajax 库的最佳选择将是能够检测到 WebSockets 支持,如果不支持 WebSockets,则回退到 Comet(长轮询)。
由于需要这两种技术来最大程度地利用所有浏览器并保持兼容性,因此建议您使用在这些技术之上提供抽象层的客户端 JavaScript 库。本系列的第 3 部分和第 4 部分将研究一些库,第 5 部分将展示这些库的应用。在服务器端,情况可能更复杂,如上一节所述。
回页首
服务器端的反向 Ajax 制约因素
大致了解客户端可用的反向 Ajax 解决方案之后,让我们来看一下服务器上的反向 Ajax 解决方案。到目前为止,示例中主要使用的是客户端 JavaScript 代码。在服务器端,为了接受反向 Ajax 连接,某些技术需要使用特定功能来处理使用期较长的连接(与您熟悉的短 HTTP 请求相比较而言)。为了更好地进行扩展,应该使用新的线程模型,该模型需要使用 Java 中的特定 API 才能够暂停请求。此外,对于 WebSockets,您需要正确管理应用程序中使用的服务的作用域。
线程和非阻塞 I/O
通常情况下,Web 服务器会将每个传入的 HTTP 连接与一个线程或一个进程相关联。这种连接可以是持久的(一直有效),因此多个请求可能使用同一个连接。在本文的示例中,可以将 Apache Web 服务器配置为 mpm_fork 或 mpm_worker 模型来改变这种行为。Java Web 服务器(应用服务器也包括在内)通常为每个传入连接使用一个线程。
生成新的线程会导致内存消耗和资源浪费,因为不能保证生成的线程会被使用。可能会已经建立连接,但没有从客户端或服务器发送数据。无论是否使用该线程,都会消耗内存和 CPU 资源来调度和争用开关。使用线程模型配置服务器时,通常需要配置一个线程池(设置处理传入连接的最大线程数)。如果错误地配置了该值,并且该值过低,那么您将遭遇线程匮乏问题;请求将一直处于等待状态,直到有了可用来处理这些请求的线程。在达到最大并发连接后,响应时间会延长。另一方面,配置较高的线程数可能导致内存不足异常。生成过多的线程会消耗 JVM 的所有堆内存,并导致服务器崩溃。
Java 最近推出了称为非阻塞 I/O 的新的 I/O API。该 API 使用一个选择器,避免每次执行连接到服务器的新 HTTP 连接时都绑定一个线程。有传入数据时,系统会收到一个事件,并分配一个线程来处理请求。因此,这也被称为 “每个请求一个线程” 模型。它允许 WebSphere 和 Jetty 等 Web 服务器进行扩展,并使用固定数量的线程处理越来越多的用户连接。在相同的硬件配置下,在这种模型下运行的 Web 服务器比 “每个连接一个线程” 模型具有更好的扩展性。
在 Philip McCarthy( Comet and Reverse Ajax 的作者)的博客中,他提供了关于两种线程模型的可扩展性的一个有趣基准(请参阅 参考资料 以获得链接)。在 图 2 中,您会发现相同的模型:当使用过多的连接时,线程模型会停止工作。
图 2. 线程模型基准
“每个连接一个线程” 模型(图 2 中的线程)通常会提供更快的响应,因为所有线程都已启用、准备就绪并等待使用,但是当连接数量过多时,则会停止服务。在 “每个请求一个线程” 模型下(图 2 的续图),要使用一个线程为到达的请求提供服务,而连接是通过 NIO 选择器进行处理的。响应时间可能会长一些,但是可以将线程回收利用,因此该解决方案在连接数量较大时扩展性更好一些。
为了了解线程的幕后工作方式,可以将 LEGO™ 块想象成为一个选择器。每个传入请求都连接到该 LEGO 块,并通过引脚识别。LEGO 块/选择器将拥有与连接数量相同的 PIN(和密钥)。然后,在等待新事件发生时,只需要使用一个线程在 PIN 上进行迭代。当发生事件时,选择器线程会检索发生事件的密钥,然后使用一个线程为传入请求提供服务。
"Rox Java NIO Tutorial" 提供了使用了用 Java 编译的 NIO 的良好示例(参阅 参考资料)。
回页首
请求作用域服务
许多框架都提供了服务或过滤器,处理到达 servlet 的 Web 请求。例如,过滤器将执行以下操作:
- 将 JDBC 连接绑定到请求线程上,整个请求只使用一个连接。
- 在请求结束时进行变更。
另一个示例是 Google Guice 的 Guice Servlet 扩展(一个依赖项注入库)。与 Spring 一样,Guice 能够在请求的作用域内绑定服务。对每个新请求,一次最多只能创建一个实例(参阅 参考资料 以获得更多相关信息)。
典型用法包括使用来自集群 HTTP 会话的用户 id,缓存从请求中的信息库(如数据库)检索的用户对象。在 Google Guice 中,您可以获得类似于 清单 7 的代码。
清单 7. 请求作用域绑定
@Provides @RequestScoped Member member(AuthManager authManager, MemberRepository memberRepository) { return memberRepository.findById(authManager.getCurrentUserId()); }
在将一个成员注入某个类时,Guice 会尝试从请求中提取它。如果没有找到它,Guice 会执行信息库调用,并将结果放在请求中。
请求作用域的服务可以与任何反向 Ajax 解决方案配套使用,除了 WebSockets。任何其他解决方案,无论是短期还是长期的,都将依赖于 HTTP 请求,因此每个请求都通过 servlet 调度系统,并执行过滤。完成暂停的(长期)HTTP 请求后,您将在本系列的后续部分看到,还有一个选项可以使请求再次通过过滤器链。
对于 WebSockets,与在 TCP 套接字中一样,数据将直接到达 onMessage
回调。因为没有针对该数据而传入的 HTTP 请求,因此没有决定从哪个请求中获得并存储作用域对象的请求争用。因此,使用需要从 onMessage
回调的作用域对象的服务会失败。
下载源代码 中的 guice-and-websocket 示例展示了如何绕过限制,在 onMessage
回调中仍然使用请求作用域对象。当您运行该示例并单击页面上的每个按钮来测试 Ajax 调用(请求作用域)、WebSocket 调用、带有模拟请求作用域的 WebSocket 调用时,您将获得如 图 3 中所示的输出。
图 3. 使用请求作用域服务的 WebSocket 处理器的输出
无论使用以下哪个选项,都可能都会遇到这样的问题:
- Spring。
- Hibernate。
- 任何其他需要请求作用域或 “每个请求” 模型的框架,如
OpenSessionInViewFilter
。 - 使用
ThreadLocal
工具在过滤器中将变量限制在请求线程中并在以后对其进行访问的系统。
Guice 有一个良好的解决方法,如下所示 清单 8:
清单 8. 从 WebSocket onMessage
回调模拟请求作用域
// The reference to the request is hold when the // doWebSocketMethod is called HttpServletRequest request = [...] Map<Key<?>, Object> bindings = new HashMap<Key<?>, Object>(); // I have a service which needs a request to get the session, // so I provide the request, but you could provide any other // binding that may be needed bindings.put(Key.get(HttpServletRequest.class), request); ServletScopes.scopeRequest(new Callable<Object>() { @Override public Object call() throws Exception { // call your repository or any service using the scoped objects outbound.sendMessage([...]); return null; } }, bindings).call();
回页首
暂停长期请求
使用 Comet 时有另一个障碍。服务器如何能够在不影响性能的前提下暂停长期请求,然后在服务器事件到达时恢复并完成该请求?
显然,您不能只是保留请求和响应,这会导致线程匮乏和内存消耗过高。除了非阻塞 I/O,暂停长轮询请求还需要一个特定的 API。在 Java 中,Servlet 3.0 规范提供了一个这样的 API(参见本系列的 反向 Ajax,第 1 部分:Comet 简介)。清单 9 展示了一个示例。
清单 9. 通过 Servlet 3.0 定义异步 servlet
<?xml version="1.0" encoding="UTF-8"?> <web-app version="3.0" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:j2ee="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml /ns/j2ee/web-app_3.0.xsd"> <servlet> <servlet-name>events</servlet-name> <servlet-class>ReverseAjaxServlet</servlet-class> <async-supported>true</async-supported> </servlet> <servlet-mapping> <servlet-name>events</servlet-name> <url-pattern>/ajax</url-pattern> </servlet-mapping> </web-app>
在定义了异步 servlet 后,您可以使用 Servlet 3.0 API 暂停和恢复请求,如下所示清单 10:
清单 10. 暂停和恢复请求
AsyncContext asyncContext = req.startAsync(); // Hold the asyncContext reference somewhere // Then when needed, in another thread you can resume or complete HttpServletResponse req = (HttpServletResponse) asyncContext.getResponse(); req.getWriter().write("data"); req.setContentType([...]); asyncContext.complete();
在 Servlet 3.0 之前,每个容器都曾拥有(目前依然拥有)自己的机制。Jetty 的后续产品就是一个众所周知的示例;Java 中的许多反向 Ajax 库都依赖于 Jetty 的后续产品。这算不上是阻碍,不要求您一定要在 Jetty 容器中运行您的应用程序。API 可以非常智能地检测您正在运行的容器,如果在 Tomcat 或 Grizzly 等其他容器中运行时,则会回退到 Servlet 3.0 API(如果有的话)。这种情况同样适用于 Comet。但是如果您想要充分利用 WebSockets,那么目前别无选择,只能使用特定于容器的功能。
Servlet 3.0 规范尚未公布,但是许多容器已经实现了该 API,因为它是执行反向 Ajax 的标准方式。