反向Ajax,第2部分:WebSocket

这一文章系列探讨了如何使用反向Ajax(Reverse Ajax)技术来开发事件驱动的web应用,第1部分内容介绍了实现反向Ajax通信的几种不同方式:轮询(polling)、捎带(piggyback)以及使用了长轮询(long-polling)和流(streaming)的Comet。在本文中,我们学习一种新的实现反向Ajax的技术:使用WebSocket,一个新的HTML5 API。WebSocket可由浏览器厂商来做本地化实现,或是通过把调用委托给隐藏的被称为FlashSocket的Flash组件这种桥接手段来实现。本文还讨论了反向Ajax技术带来的一些服务器端约束。

前言


时至今日,用户期待的是可通过web访问快速、动态的应用。这一文章系列展示了如何使用反向Ajax(Reverse Ajax)技术来开发事件驱动的web应用。系列的第1部分介绍了反向Ajax、轮询(polling)、流(streaming)、Comet和长轮询(long polling)。你已经了解了Comet是如何使用HTTP长轮询的,这是可靠地实现反向Ajax的最好方式,因为现有的所有浏览器都提供支持。

在本文中,我们学习如何使用WebSocket来实现反向Ajax。代码例子被用来帮助说明WebSocket、FlashSocket、服务器端约束、请求作用域(request-scoped)服务以及暂停长生存期请求等,你可以下载本文中用到的这些源代码。

前提条件

理想情况下,要充分体会本文的话,你应该对JavaScrpit和Java有一定的了解。本文中创建的例子是使用Google Guice来构建的,这是一个使用Java编写的依赖注入框架。若要读懂文中所谈内容,你应该要熟悉诸如Guice、Spring或是Pico一类的依赖注入框架的概念。

若要运行本文中的例子,你还需要最新版本的Maven和JDK(参见参考资料)。

WebSocket


在HTML5中出现的WebSocket是一种比Comet还要新的反向Ajax技术,WebSocket启用了双向的全双工通信信道,许多浏览器(Firefox、Google Chrome和Safari)都已对此做了支持。连接是通过一个被称为WebSocket握手的HTTP请求打开的,其用到了一些特殊的报头。连接会保持在活动状态,你可以使用JavaScript来写入和接收数据,就像是在使用一个原始的TCP套接口一样。

WebSocket URL的起始输入是ws://或是wss://(在SSL上)。

图1中的时间线说明了使用WebSocket的通信。一个带有特定报头的HTTP握手被发送到了服务器端,接着在服务器端或是客户端就可以通过JavaScript来使用某种套接口(socket)了,这一套接口可被用来通过事件句柄异步地接收数据。

图1. 使用WebSocket的反向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中你会如清单2所说明的那样来使用WebSocket,如果你的浏览器支持它的话。

清单2. JavaScript客户端例子

var ws = new WebSocket('ws://127.0.0.1:8080/async');
ws.onopen = function() {
    // 连接被打开时调用
};
ws.onerror = function(e) {
    // 在出现错误时调用,例如在连接断掉时
};
ws.onclose = function() {
    // 在连接被关闭时调用
};
ws.onmessage = function(msg) {
    // 在服务器端向客户端发送消息时调用
// msg.data包含了消息
};
// 这里是如何给服务器端发送一些数据
ws.send('some data');
// 关闭套接口
ws.close();

发送和接收的数据可以是任意类型的,WebSocket可被看成是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
  返回WebSocket服务器的URL

ws.readyState
  返回当前连接状态的值
1. CONNECTING = 0
         2. OPEN = 1
         3. CLOSED = 2

服务器端对WebSocket的处理要稍加复杂一些,现在还没有某个Java规范以一种标准的方式来支持WebSocket。要使用web容器(例如Tomcat或是Jetty)的WebSocket功能的话,你得把应用代码和容器特定的库紧密耦合在一起才能访问WebSocket的功能。

示例代码的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连接的某种端点(endpoint)。清单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) {
        // 在接收到消息时调用
// 你通常用到的就是这一方法
}

    @Override
    public void onFragment(boolean more, byte opcode,
                           byte[] data, int offset, int length) {
        // 在完成一段内容时,onMessage被调用
// 通常不在这一方法中写入东西
}

    @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;
    }
}

若要向客户端发送消息的话,你要向outbound中写入消息,如果清单6所示:

清单6. 发送消息给客户端

if (outbound != null && outbound.isOpen()) {
    outbound.sendMessage('Hello World !');
}

要断开并关闭到客户端的WebSocket连接的话,使用outbound.disconnect()。

WebSocket是一种实现无延迟双向通信的非常强大的方法,Firefox、Google Chrome、Opera和其他的现代浏览器都支持这种做法。根据jWebSocket网站的说法:

1. Chrome从4.0.249版本开始包含本地化的WebSocket。
2. Safari 5.x包含了本地化的WebSocket。
3. Firefox 3.7a6和4.0b1+包含了本地化的WebSocket。
4. Opera从10.7.9.67开始包含了本地化的WebSocket。

欲了解更多关于jWebSocket方面的内容,请查阅参考资料。

优点

WebSocket功能强大、双向、低延迟,且易于处理错误,其不会像Comet长轮询那样有许多的连接,也没有Comet流所具有的一些缺点。它的API也很容易使用,无需另外的层就可以直接使用,而Comet则需要一个很好的库来处理重连接、超时、Ajax请求、确认以及选择不同的传输(Ajax长轮询和jsonp轮询)。

缺点

WebSocket的缺点有这些:

1. 是一个来自HTML5的新规范,还没有被所有的浏览器支持。

2. 没有请求作用域(request scope),因为WebSocket是一个TCP套接口而不是一个HTTP请求,有作用域的请求服务,比如说Hibernate的SessionInViewFilter,就不太容易使用。Hibernate是一个持久性框架,其在HTTP请求的外围提供了一个过滤器。在请求开始时,其在请求线程中设定了一个上下文(包括事务和JDBC连接)边界;在请求结束时,过滤器销毁这一上下文。

FlashSocket


对于不支持WebSocket的浏览器来说,有些库能够回退到FlashSocket(经由Flash的套接口)上。这些库通常会提供同样的官方WebSocket API,但他们是通过把调用委托给一个包含在网站中的隐藏的Flash组件来实现的。

优点

FlashSocket透明地提供了WebSocket的功能,即使是在不支持HTML5 WebSocket的浏览器上也是如此。

缺点

FlashSocket有着下面的这些缺点:

1. 其需要安装Flash插件(通常情况下,所有浏览器都会有该插件)。

2. 其要求防火墙的843端口是打开的,这样Flash组件才能发出HTTP请求来检索包含了域授权的策略文件。如果843端口是不可到达的话,则库应该有回退动作或是给出一个错误,所有的这些处理都需要一些时间(最多3秒,这取决于库),而这会降低网站的速度。

3. 如果客户端处在某个代理服务器的后面的话,到端口843的连接可能会被拒绝。

WebSocketJS项目提供了一种桥接方式,其要求一个至少是10版本的Flash来为Firefox 3、Inernet Explorer 8和Internet Explorer 9提供WebSocket支持。

建议

相比于Comet,WebSocket带来了更多的好处。在日常开发中,客户端支持的WebSocket速度更快,且产生较少的请求(从而消耗更少的带宽)。不过,由于并非所有的浏览器都支持WebSocket,因此,对于Reverse Ajax库来说,最好的选择就是能够检测对WebSocket的支持,并且如果不支持WebSocket的话,还能够回退到Comet(长轮询)上。

由于这两种技术需要从所有浏览器中获得最好的做法并保持兼容性,因此我的建议是使用一个客户端的JavaScript库,该库在这些技术之上提供一个抽象层。本系列的第3和第4部分内容会探讨一些库,第5部分则是说明它们的应用。在服务器端,正如下一节内容讨论的那样,事情则会稍加复杂一些。

服务器端的反向Ajax约束


现在你对客户端可用的反向Ajax解决方案已经有了一个概观,让我们再来看看服务器端的反向Ajax解决方案。到目前为止,例子使用的都还主要是客户端的JavaScript代码。在服务器端,要接受反向Ajax连接的话,相比你所熟悉的短HTTP请求,某些技术需要特定的功能来处理长生存期的连接。为了得到更好的伸缩性,应该要使用一种新的线程模型,该模型需要Java中的某个特定API来暂停请求。还有,就WebSocket来说,你必须要正确地管理应用中用到的服务的作用域。

线程和非阻塞I/O

通常情况下,web服务器会把一个线程或是一个进程与每个传入的HTTP连接关联起来。这一连接可以是持久的(保持活动),这样多个请求就可以通过这同一个连接进行了。在本文的例子中,Apache web服务器可以配置成mpm_fork或是mpm_worker模式来改变这一行为。Java web服务器(应用服务器也包括在内——这是同一回事)通常会为每个传入的连接使用单独的一个线程。

产生一个新的线程会带来内存的消耗和资源的浪费,因为其并不保证产生的线程会被用到。连接可能会建立起来,但是没有来自客户端或是服务器端的数据在发送。不管这一线程是否被用到,其都会消耗用于调度和上下文切换的内存和CPU资源。而且,在使用线程模式来配置服务器时,你通常需要配置一个线程池(设定处理传入连接的线程的最大数目)。如果该值配置不当,值太小的话,你最终就会遭遇线程饥饿问题;请求就会一直处于等待状态直到有线程可用来处理它们,在达到最大并发连接时,响应时间就会下降。另一方面,配置一个高值则可会导致内存不足的异常,产生过多线程会消耗尽JVM的所有可用的堆,导致服务器崩溃。

Java最近引入一个新的I/O API,其被称为非阻塞式的I/O。这一API使用一个选择器来避免每次有新的HTTP连接在服务器端建立时都要绑定一个线程的做法,当有数据到来时,就会有一个事件被接收,接着某个线程就被分配来处理该请求。因此,这种做法被称为每个请求一个线程(thread-per-request)模式。其允许web服务器,比如说WebSphere和Jetty等,使用固定数量的线程来容纳并处理越来越多的用户连接。在相同硬件配置的情况下,在这一模式下运行的web服务器的伸缩性要比运行在每个连接一个线程(thread-per-connection)模型下的好得多。

在Philip McCarthy(Comet and Reverse Ajax的作者)的博客中,关于这两种线程模式的可伸缩性有一个很有意思的衡量基准(参见参考资料中的链接)。在图2中,你会发现同样的模式:在有太多连接时,线程模式会停止工作。

图2. 线程模式的衡量基准


每个连接一个线程模式(图2中的Threads)通常会有一个更好的响应时间,因为所有的线程都已启动、准备好且是等待中,但在连接的数目过高时,其会停止提供服务。在每个请求一个线程模式(图2中的Continuations)中,线程被用来为到达的请求提供服务,连接则是通过一个NIO选择器来处理。响应时间可能会较慢一些,但线程会回收再用,因此该方案在大容量连接方面有着更好的伸缩性。

想要了解线程在幕后是如何工作的话,可以把一个LEGO™积木块想象成是选择器,每次传入的连接到达这一LEGO积木块时,其由一个管脚来标识。LEGO积木块/选择器有着与连接数一样多的管脚(一样多的键)。那么,只需要一个线程来等待新事件的发生,然后在这些管脚上遍历就可以了。当有事情发生时,选择器线程从发生的事件中检索出键值,然后就可以使用一个线程来为传入的请求提供服务。

“Rox Java NIO Tutorial”这一教程有很好的使用Java中的NIO的例子(参见参考资料)。

有请求作用域的服务


许多框架都提供了服务或是过滤器(filter)来处理到达servlet的web请求,例如,某个过滤器会:

1. 把JDBC连接绑定到某个请求线程上,这样整个请求就只用到一个连接。

2. 在请求结束时提交所做的改变。

另一个例子是Google Guice(一个依赖注入库)的Guice Servlet扩展。类似于Spring,Guice可把服务绑定在请求的作用域内,一个实例至多只会为每个新请求创建一次(参阅参考资料获得更多信息)。

通常的做法包括了使用用户id来把从储存库中检索出来的用户对象缓存在请求中,用户id则是取自集群化的HTTP会话。在Google Guice中,你可能会有类似清单7中给出的代码。

清单7. 请求作用域的绑定

@Provides
@RequestScoped
Member member(AuthManager authManager,
              MemberRepository memberRepository) {
    return memberRepository.findById(authManager.getCurrentUserId());
}

当某个member被注入到类中时,Guice会尝试这从请求中获取该对象,如果没有找到的话,它就会执行储存库调用并把结果放在请求中。

请求作用域可与除了WebSocket之外的其他任何的反向Ajax解决方案一起使用,任何其他的依赖于HTTP请求的解决方案,无论是短的还是长的生存期的都可以,每个请求都会通过servlet分发系统,过滤器都会被执行。在完成一个暂停的(长生存其)HTTP请求时,你会在这一系列的后继部分中了解到还有另一种做法可让请求再次通过过滤器链。

对于WebSocket来说,数据直接到达onMessage回调函数上,就像是在TCP套接口中的情况那样。不存在任何的HTTP请求送达这一数据,故也不存在获取或是存放作用域对象的请求上下文。因此在onMessage回调中使用需要作用域对象的服务就会失败。可下载源代码中的guice-and-websocket例子说明了如何绕过这一限制,以便仍然可在onMessage回调中使用请求作用域对象。当你运行这一例子,并在网页上点击每个按钮来测试一个Ajax调用(有请求作用域的)、一个WebSocket调用和一个使用了模拟请求作用域的WebSocket调用时,你会得到图3所示的输出。

图3. 使用了请求作用域服务的WebSocket处理程序


在使用下面任一种技术时,你可能都会遇到这些问题:

1. Spring

2. Hibernate

3. 任何其他需要请求作用域或是每一请求模型的框架,比如说OpenSessionInViewFilter。

4. 任何在过滤器的内部使用ThreadLocal这一设施来指定变量的作用域为请求线程并在以后访问这些变量的系统。

Guice有一个优雅的解决方案,如清单8所示:

清单8. 在WebSocket的onMessage回调中模拟一个请求作用域

// 在调用doWebSocketMethod时
// 保存到请求的引用
HttpServletRequest request = [...]
Map<key<?>, Object> bindings = new HashMap<key<?>, Object>();
// 我有一个服务需要一个请求来获取会话
// 因此我提供一个请求,但你可以提供任何其他
// 可能需要的绑定
bindings.put(Key.get(HttpServletRequest.class), request);
ServletScopes.scopeRequest(new Callable() {
    @Override
    public Object call() throws Exception {
        // 调用你的储存库或是任何用到作用域对象的服务
outbound.sendMessage([...]);
        return null;
    }
}, bindings).call();

暂停长生存期请求


若使用Comet的话,还有另一障碍存在,那就是服务器端如何在不影响性能的情况下暂停一个长生存期请求,然后在服务器端事件到来时尽可能快地恢复并完成请求呢?

很显然,你不能简单地让请求和响应停在那里,这会引发线程饥饿和高内存消耗。暂停非阻塞式的I/O中的一个长生存期请求,在Java中这需要一个特有的API。Servlet 3.0规范提供了这样的一个API(参见本系列的第1部分内容)。清单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();
//
asyncContext的引用保存在某处

//
然后在需要的时候,在另一个线程中你可以恢复并完成
HttpServletResponse req =
     (HttpServletResponse) asyncContext.getResponse();
req.getWriter().write("data");
req.setContentType([...]);
asyncContext.complete();

在Servlet 3.0之前,每个容器都有着且现在仍有着自己的机制。Jetty的延续(continuation)就是一个很有名的例子;Java中的许多反向Ajax库都依赖于Jetty的continuation。其并非什么精彩绝伦的做法,也不需要你的应用运行在Jetty容器上。该API的聪明之处在于其能够检测出你正在运行的容器,如果是运行在另一个容器上,比如说Tomcat或是Grizzly,那么如果Servlet 3.0 API可用的话,就回退到Servlet 3.0 API上。这对于Comet来说没有问题,但如果你想要利用WebSocket的优势的话,目前别无选择,只能使用容器特有的功能。

Servlet 3.0规范还没有发布,但许多容器都已经实现了这一API,因为这也是实施反向Ajax的一种标准做法。

结束语


WebSocket尽管存在一些不足之处,但却是一个功能非常强大的反向Ajax解决方案。其目前还未在所有浏览器上实现,且如果没有反向Ajax库的帮助的话,在Java服务器端并不容易使用。因为你使用的不是标准的请求-响应风格,所有你不能依赖过滤器链的作用域执行。Comet和WebSocket需要服务器端的容器特定功能,因此在使用新出的容器时,你需要注意一下,它可能没有做这方面的扩充。

请继续关注这一系列的第3部分,该部分内容将探讨用于Comet和WebSocket的不同的服务器端API,你还可了解到Atomsphere,这是一个反向Ajax框架。

 

http://select.yeeyan.org/view/213582/213044

展开阅读全文

没有更多推荐了,返回首页