一般来说Web端即时通讯技术因受限于浏览器的设计限制一直以来实现起来并不容易主流的Web端即时通讯方案大致有4种传统Ajax短轮询、Comet技术、WebSocket技术、SSEServer-sent Events。关于这4种技术方式的优缺点请参考《Web端即时通讯技术盘点短轮询、Comet、Websocket、SSE》。本文将专门讲解SSE技术。

服务器推送事件Server-sent Events简称SSE是 HTML 5 规范中的一个组成部分可以用来从服务端实时推送数据到浏览器端。相对于与之类似的 COMET 和 WebSocket 技术来说服务器推送事件的使用更简单对服务器端的改动也比较小。对于某些类型的应用来说服务器推送事件是最佳的选择。

本文对服务器推送技术SSE进行了详细的介绍包含浏览器端和服务器端的相应实现细节为在实践中使用该技术提供了指南。本文同步发布于http://www.52im.net/thread-335-1-1.html

学习交流

- 即时通讯开发交流群 215891622 [推荐]

- 更多即时通讯技术资料http://www.52im.net/forum.php?mod=collection&op=all

概述

对于一般的 Web 应用开发大多数开发人员并不陌生。在 Web 应用中浏览器和服务器之间使用的是请求 / 响应的交互模式。浏览器发出请求服务器根据收到的请求来生成相应的响应。浏览器再对收到的响应进行处理展现给用户。响应的格式可能是 HTML、XML 或 JSON 等。随着 REST 架构风格和 AJAX 的流行服务器更多地使用 JSON 作为响应的数据格式。Web 应用使用 XMLHttpRequest 对象来发送请求并根据服务器端返回的数据对页面的内容进行动态更新。通常来说用户在页面上的操作比如点击或移动鼠标会触发相应的事件。由 XMLHttpRequest 对象来发出请求得到服务器响应之后进行页面的局部更新。这种方式的不足之处在于服务器端产生的数据变化不能及时地通知浏览器而是需要等到下次请求发出时才能被浏览器获取。对于某些对数据实时性要求很高的应用来说这种延迟是不能接受的。

为了满足这类应用的需求就需要有某种方式能够从服务器端推送数据给浏览器以保证服务器端的数据变化可以在第一时间通知给用户。目前常见的解决办法有不少主要可以分成两类。这两类方法的区别在于是否基于 HTTP 协议来实现。不使用 HTTP 协议的做法是使用 HTML 5 新增的 WebSocket 规范而使用 HTTP 协议的做法则包括简易轮询、COMET 技术和本文中要介绍的 HTML 5 服务器推送事件。下面会对这几种技术进行介绍。

基本介绍

在介绍 HTML 5 服务器推送事件SSE技术之前首先介绍一些上面提到的几种服务器端数据推送技术。

第一种是 WebSocket。WebSocket 规范是 HTML 5 中的一个重要组成部分已经被很多主流浏览器所支持也有不少基于 WebSocket 开发的应用。正如名称所表示的一样WebSocket 使用的是套接字连接基于 TCP 协议。使用 WebSocket 之后实际上在服务器端和浏览器之间建立一个套接字连接可以进行双向的数据传输。WebSocket 的功能是很强大的使用起来也灵活可以适用于不同的场景。不过 WebSocket 技术也比较复杂包括服务器端和浏览器端的实现都不同于一般的 Web 应用。而且更不幸的是WebSocket像其它较新的Web端技术一样存在浏览器兼容性问题好在已经比较成熟的封装方案来解决这种技术限制比如开源的Socket.io详见《Socket.IO介绍支持WebSocket、用于WEB端的即时通讯的框架》。

除了 WebSocket 之外其他的实现方式是基于 HTTP 协议来达到实时推送的效果。第一种做法是简易轮询即浏览器端定时向服务器端发出请求来查询是否有数据更新。这种做法比较简单可以在一定程度上解决问题。不过对于轮询的时间间隔需要进行仔细考虑。轮询的间隔过长会导致用户不能及时接收到更新的数据轮询的间隔过短会导致查询请求过多增加服务器端的负担。

Comet 技术改进了简易轮询的缺点详见Comet技术详解基于HTTP长连接的Web端实时通信技术使用的是长轮询。长轮询的方式在每次请求时服务器端会保持该连接在一段时间内处于打开状态而不是在响应完成之后就立即关闭。这样做的好处是在连接处于打开状态的时间段内服务器端产生的数据更新可以被及时地返回给浏览器。当上一个长连接关闭之后浏览器会立即打开一个新的长连接来继续请求。不过 COMET 技术的实现在服务器端和浏览器端都需要第三方库的支持。

综合比较上面提到的 4 种不同的技术简易轮询由于其本身的缺陷并不推荐使用。Comet 技术并不是 HTML 5 标准的一部分从兼容标准的角度出发也不推荐使用。WebSocket 规范和服务器推送技术都是 HTML 5 标准的组成部分在主流浏览器上都提供了原生的支持是推荐使用的。不过 WebSocket 规范更加复杂一些适用于需要进行复杂双向数据通讯的场景。对于简单的服务器数据推送的场景使用服务器推送SSE技术事件就足够了。

在浏览器支持方面服务器推送事件SSE技术已经在除 IE 外的大部分桌面和移动浏览器上得到了支持。支持服务器推送事件的浏览器及其版本包括Firefox 6.0+、Chrome 6.0+、Safari 5.0+、Opera 11.0+、iOS Safari 4.0+、Opera Mobile 11.1+、Chrome for Android 25.0+、Firefox for Android 19.0+ 以及 Blackberry Browser 7.0+ 等。关于 IE 的支持在下面的章节中有详细的介绍。

下面对服务器推送事件SSE技术的规范进行具体的说明。

与WebSocket的比较

简单不说SSE适用于更新频繁、低延迟并且数据都是从服务端到客户端。

它和WebSocket的区别

  • 便利不需要添加任何新组件用任何习惯的后端语言和框架就能继续使用不用为新建虚拟机弄一个新的IP或新的端口号而劳神。

  • 服务器端的简洁。因为SSE能在现有的HTTP/HTTPS协议上运作所以它能够直接运行于现有的代理服务器和认证技术。

WebSocket相较SSE最大的优势在于它是双向交流的这意味着服务器发送数据就像从服务器接受数据一样简单而SSE一般通过一个独立的Ajax请求从客户端向服务端传送数据因此相对于WebSocket使用Ajax会增加开销。因此如果需要以每秒一次或者更快的频率向服务端传输数据就应该用WebSocket。

SSEServer-sent Events在HTML 5中的技术规范和定义

Server-sent Events 规范是 HTML 5 规范的一个组成部分具体的规范文档见参考资源。该规范比较简单主要由两个部分组成第一个部分是服务器端与浏览器端之间的通讯协议第二部分则是在浏览器端可供 JavaScript 使用的 EventSource 对象。通讯协议是基于纯文本的简单协议。服务器端的响应的内容类型是“text/event-stream”。响应文本的内容可以看成是一个事件流由不同的事件所组成。每个事件由类型和数据两部分组成同时每个事件可以有一个可选的标识符。不同事件的内容之间通过仅包含回车符和换行符的空行“\r\n”来分隔。每个事件的数据可能由多行组成。代码清单 1 给出了服务器端响应的示例。

清单 1. 服务器端响应的示例

data: first eventdata: second eventid: 100event: myeventdata: third eventid: 101: this is a commentdata: fourth eventdata: fourth event continue



如代码清单 1 所示每个事件之间通过空行来分隔。对于每一行来说冒号“:”前面表示的是该行的类型冒号后面则是对应的值。可能的类型包括
 

  • 类型为空白表示该行是注释会在处理时被忽略。

  • 类型为 data表示该行包含的是数据。以 data 开头的行可以出现多次。所有这些行都是该事件的数据。

  • 类型为 event表示该行用来声明事件的类型。浏览器在收到数据时会产生对应类型的事件。

  • 类型为 id表示该行用来声明事件的标识符。

  • 类型为 retry表示该行用来声明浏览器在连接断开之后进行再次连接之前的等待时间。


在代码清单 1 中第一个事件只包含数据“first event”会产生默认的事件第二个事件的标识符是 100数据为“second event”第三个事件会产生类型为“myevent”的事件最后一个事件的数据为“fourth event\nfourth event continue”。当有多行数据时实际的数据由每行数据以换行符连接而成。

如果服务器端返回的数据中包含了事件的标识符浏览器会记录最近一次接收到的事件的标识符。如果与服务器端的连接中断当浏览器端再次进行连接时会通过 HTTP 头“Last-Event-ID”来声明最后一次接收到的事件的标识符。服务器端可以通过浏览器端发送的事件标识符来确定从哪个事件开始来继续连接。

对于服务器端返回的响应浏览器端需要在 JavaScript 中使用 EventSource 对象来进行处理。EventSource 使用的是标准的事件监听器方式只需要在对象上添加相应的事件处理方法即可。EventSource 提供了三个标准事件如表 1 所示。

表 1. EventSource 对象提供的标准事件
SSE技术详解:一种全新的HTML5服务器推送事件技术_第1张图片 

如之前所述服务器端可以返回自定义类型的事件。对于这些事件可以使用 addEventListener 方法来添加相应的事件处理方法。代码清单 2 给出了 EventSource 对象的使用示例。

清单 2. EventSource 对象的使用示例

var es = new EventSource('events');

es.onmessage = function(e) {

    console.log(e.data);

};



es.addEventListener('myevent', function(e) {

    console.log(e.data);

});



如代码清单 2 所示在指定 URL 创建出 EventSource 对象之后可以通过 onmessage 和 addEventListener 方法来添加事件处理方法。当服务器端有新的事件产生相应的事件处理方法会被调用。EventSource 对象的 onmessage 属性的作用类似于 addEventListener( ‘ message ’ )不过 onmessage 属性只支持一个事件处理方法。

在介绍完服务器推送事件的规范内容之后下面介绍服务器端的实现。

SSE实战示例服务器端和浏览器端实现

从上一节中对通讯协议的描述可以看出服务器端推送事件是一个比较简单的协议。服务器端的实现也相对比较简单只需要按照协议规定的格式返回响应内容即可。在开源社区可以找到各种不同的服务器端技术相对应的实现。自己开发的难度也不大。本文使用 Java 作为服务器端的实现语言。相应的实现基于开源的 jetty-eventsource-servlet 项目见参考资源。下面通过一个具体的示例来说明如何使用 jetty-eventsource-servlet 项目。示例用来模拟一个物体在某个限定空间中的随机移动。该物体从一个随机位置开始然后从上、下、左和右四个方向中随机选择一个方向并在该方向上移动随机的距离。服务器端不断改变该物体的位置并把位置信息推送给浏览器由浏览器来显示。
 

1服务器端实现

服务器端的实现由两部分组成一部分是用来产生数据的 org.eclipse.jetty.servlets.EventSource 接口的实现另一部分是作为浏览器访问端点的继承自 org.eclipse.jetty.servlets.EventSourceServlet 类的 servlet 实现。代码清单 3 给出了 EventSource 接口的实现类。

清单 3. EventSource 接口的实现类 MovementEventSource

public class MovementEventSource implements EventSource {

         

        private int width = 800;

        private int height = 600;

        private int stepMax = 5;

        private int x = 0;

        private int y = 0;

        private Random random = new Random();

        private Logger logger = Logger.getLogger(getClass().getName());

         

        public MovementEventSource(int width, int height, int stepMax) {

                this.width = width;

                this.height = height;

                this.stepMax = stepMax;

                this.x = random.nextInt(width);

                this.y = random.nextInt(height);

        }



        @Override

        public void onOpen(Emitter emitter) throws IOException {

                query(emitter); //开始生成位置信息         }        @Override

        public void onResume(Emitter emitter, String lastEventId)

                        throws IOException {

                updatePosition(lastEventId); //更新起始位置                 query(emitter);  //开始生成位置信息         }         

        //根据Last-Event-Id来更新起始位置         private void updatePosition(String id) {                if (id != null) {

                        String[] pos = id.split(",");

                        if (pos.length > 1) {

                                int xPos = -1, yPos = -1;

                                try {

                                        xPos = Integer.parseInt(pos[0], 10);

                                        yPos = Integer.parseInt(pos[1], 10);

                                } catch (NumberFormatException e) {

                                         

                                }

                                if (isValidMove(xPos, yPos)) {

                                        x = xPos;

                                        y = yPos;

                                }

                        }

                }

        }

         

        private void query(Emitter emitter) throws IOException {

                emitter.comment("Start sending movement information.");

                while(true) {

                        emitter.comment("");

                        move(); //移动位置                         String id = String.format("%s,%s", x, y);                        emitter.id(id); //根据位置生成事件标识符                         emitter.data(id); //发送位置信息数据                         try {                                Thread.sleep(2000);

                        } catch (InterruptedException e) {

                                logger.log(Level.WARNING, \

               "Movement query thread interrupted. Close the connection.", e);

                                break;

                        }

                }

                emitter.close(); //当循环终止时关闭连接         }        @Override

        public void onClose() {

                 

        }

         

        //获取下一个合法的移动位置         private void move() {                while (true) {

                        int[] move = getMove();

                        int xNext = x + move[0];

                        int yNext = y + move[1];

                        if (isValidMove(xNext, yNext)) {

                                x = xNext;

                                y = yNext;

                                break;

                        }

                }

        }



        //判断当前的移动位置是否合法         private boolean isValidMove(int x, int y) {                return x >= 0 && x <= width && y >=0 && y <= height;

        }

         

        //随机生成下一个移动位置         private int[] getMove() {                int[] xDir = new int[] {-1, 0, 1, 0};

                int[] yDir = new int[] {0, -1, 0, 1};

                int dir = random.nextInt(4);

                return new int[] {xDir[dir] * random.nextInt(stepMax), \

                   yDir[dir] * random.nextInt(stepMax)};

        }

}



代码清单 3 中类 MovementEventSource 需要实现 EventSource 接口的 onOpen、onResume 和 onClose 方法其中 onOpen 方法在浏览器端的连接打开的时候被调用onResume 方法在浏览器端重新建立连接时被调用onClose 方法则在浏览器关闭连接的时候被调用。onOpen 和 onResume 方法都有一个 EventSource.Emitter 接口类型的参数可以用来发送数据。EventSource.Emitter 接口中包含的方法包括 data、event、comment、id 和 close 等分别对应于通讯协议中各种不同类型的事件。而 onResume 方法还额外包含一个参数 lastEventId表示通过 Last-Event-ID 头发送过来的最近一次事件的标识符。

MovementEventSource 类中事件生成的主要逻辑在 query 方法中。该方法中包含一个无限循环每隔 2 秒钟改变一次位置同时把更新之后的位置通过 EventSource.Emitter 接口的 data 方法发送给浏览器端。每个事件都有对应的标识符而标识符的值就是位置本身。如果连接断开之后浏览器重新进行连接可以从上一次的位置开始继续移动该物体。

与 MovementEventSource 类对应的 servlet 实现比较简单只需要继承自 EventSourceServlet 类并覆写 newEventSource 方法即可。在 newEventSource 方法的实现中需要返回一个 MovementEventSource 类的对象如代码清单 4 所示。每当浏览器端建立连接时该 servlet 会创建一个新的 MovementEventSource 类的对象来处理该请求。

清单 4. servlet 实现类 MovementServlet

public class MovementServlet extends EventSourceServlet {

     @Override     protected EventSource newEventSource(HttpServletRequest request, String clientId) { 

          return new MovementEventSource(800, 600, 20);

    }

}



在服务器端实现中需要注意的是要添加相应的 servlet 过滤器支持。这是 jetty-eventsource-servlet 项目所依赖的 Jetty Continuations 框架的要求否则的话会出现错误。添加过滤器的方式是在 web.xml 文件中添加代码清单 5 中所示的配置内容。

清单 5. Jetty Continuations 所需 servlet 过滤器的配置

    continuation    org.eclipse.jetty.continuation.ContinuationFilter      continuation    /sse/* 

2浏览器端实现

浏览器端的实现也比较简单只需要创建出 EventSource 对象并添加相应的事件处理方法即可。代码清单 6 给出了相应的实现。在页面中使用一个方块表示物体。当接收到新的事件时根据事件数据中给出的坐标信息更新方块在页面上的位置。

清单 6. 浏览器端的实现代码

var es = new EventSource('sse/movement');

es.addEventListener('message', function(e) {

    var pos = e.data.split(','), x = pos[0], y = pos[1]; 

    $('#box').css({

        left : x + 'px',

        top : y + 'px'         });

    });


在介绍完基本的服务器端和浏览器端实现之后下面介绍比较重要的 IE 的支持。

IE上的兼容性问题

使用浏览器原生的 EventSource 对象的一个比较大的问题是 IE 并不提供支持。为了在 IE 上提供同样的支持一般有两种办法。第一种办法是在其他浏览器上使用原生 EventSource 对象而在 IE 上则使用简易轮询或 COMET 技术来实现另外一种做法是使用 polyfill 技术即使用第三方提供的 JavaScript 库来屏蔽浏览器的不同。本文使用的是 polyfill 技术只需要在页面中加载第三方 JavaScript 库即可。应用本身的浏览器端代码并不需要进行改动。一般推荐使用第二种做法因为这样的话在服务器端只需要使用一种实现技术即可。

在 IE 上提供类似原生 EventSource 对象的实现并不简单。理论上来说只需要通过 XMLHttpRequest 对象来获取服务器端的响应内容并通过文本解析就可以提取出相应的事件并触发对应的事件处理方法。不过问题在于 IE 上的 XMLHttpRequest 对象并不支持获取部分的响应内容。只有在响应完成之后才能获取其内容。由于服务器端推送事件使用的是一个长连接。当连接一直处于打开状态时通过 XMLHttpRequest 对象并不能获取响应的内容也就无法触发对应的事件。更具体的来说当 XMLHttpRequest 对象的 readyState 为 3READYSTATE_INTERACTIVE时其 responseText 属性是无法获取的。

为了解决 IE 上 XMLHttpRequest 对象的问题就需要使用 IE 8 中引入的 XDomainRequest 对象。XDomainRequest 对象的作用是发出跨域的 AJAX 请求。XDomainRequest 对象提供了 onprogress 事件。当 onprogress 事件发生时可以通过 responseText 属性来获取到响应的部分内容。这是 XDomainRequest 对象和 XMLHttpRequest 对象的最大不同也是使用 XDomainRequest 对象来实现类似原生 EventSource 对象的基础。在使用 XDomainRequest 对象打开与服务器端的连接之后当服务器端有新的数据产生时可以通过 XDomainRequest 对象的 onprogress 事件的处理方法来进行处理对接收到的数据进行解析根据数据的内容触发相应的事件。

不过由于 XDomainRequest 对象本来的目的是发出跨域 AJAX 请求考虑到跨域访问的安全性问题XDomainRequest 对象在使用时的限制也比较严格。这些限制会影响到其作为 EventSource 对象的实现方式。具体的限制和解决办法如下所示
 

  • 服务器端的响应需要包含 Access-Control-Allow-Origin 头用来声明允许从哪些域访问该 URL。“*”表示允许来自任何域的访问不推荐使用该值。一般使用与当前应用相同的域限制只允许来自当前域的访问。

  • XDomainRequest 对象发出的请求不能包含自定义的 HTTP 头这就限制了不能使用 Last-Event-ID 头来声明浏览器端最近一次接收到的事件的标识符。只能通过 HTTP 请求的其他方式来传递该标识符如 GET 请求的参数或 POST 请求的内容体。

  • XDomainRequest 对象的请求的内容类型Content-Type只能是“text/plain”。这就意味着当使用 POST 请求时服务器端使用的框架如 servlet不会对 POST 请求的内容进行自动解析无法使用 HttpServletRequest 类的 getParameter 方法来获取 POST 请求的内容。只能在服务器端对原始的请求内容进行解析获取到其中的参数的值。

  • XDomainRequest 对象发出的请求中不包含任何与用户认证相关的信息包括 cookie 等。这就意味着如果服务器端需要认证则需要通过 HTTP 请求的其他方式来传递用户的认证信息比如 session 的 ID 等。


由于 XDomainRequest 对象的这些限制服务器端的实现也需要作出相应的改动。这些改动包括返回 Access-Control-Allow-Origin 头对于浏览器端发送的“text/plain”类型的参数进行解析处理请求中包含的用户认证相关的信息。

本文的示例使用的 polyfill 库是 GitHub 上的 Yaffle 开发的 EventSource 项目。在使用该 polyfill 库并对服务器端的实现进行修改之后就可以在 IE 8 及以上的浏览器中使用服务器推送事件。如果需要支持 IE 7则只能使用简易轮询或 Comet 技术。本文的示例代码见参考资源。

结束语

如果需要从服务器端推送数据给浏览器可以使用的基于 HTML 5 规范标准的技术包括 WebSocket 和服务器推送事件。开发人员可以根据应用的具体需求来选择合适的技术。如果只是需要从服务器端推送数据服务器推送事件的规范更加简单实现起来更容易。本文对服务器推送事件的规范内容、服务器端和浏览器端的实现都进行了详细的介绍对如何支持 IE 浏览器也进行了具体的分析。

参考资料

[1] 服务器推送事件规范Server-sent Events
[2] jetty-eventsource-servlet 项目和 JettyContinuations框架
[3] IE 上的 XMLHttpRequest和 XDomainRequest对象了解 XDomainRequest 对象的 使用限制。
[4] 支持 IE 的 EventSource 对象的 polyfill 库的详细信息。
[5] 本文的示例代码。

本文同步发布于http://www.52im.net/thread-335-1-1.html

系列文章

Web端即时通讯新手入门贴
《新手入门贴详解Web端即时通讯技术的原理》

Web端即时通讯技术盘点请参见
《Web端即时通讯技术盘点短轮询、Comet、Websocket、SSE》

关于Ajax短轮询
找这方面的资料没什么意义除非忽悠客户否则请考虑其它3种方案即可。

有关Comet技术的详细介绍请参见
《Comet技术详解基于HTTP长连接的Web端实时通信技术》
《WEB端即时通讯HTTP长连接、长轮询long polling详解》
《WEB端即时通讯不用WebSocket也一样能搞定消息的即时性》
《开源Comet服务器iComet支持百万并发的Web端即时通讯方案》

有关WebSocket的详细介绍请参见
《WebSocket详解一初步认识WebSocket技术》
《WebSocket详解二技术原理、代码演示和应用案例》
《WebSocket详解三深入WebSocket通信协议细节》
《Socket.IO介绍支持WebSocket、用于WEB端的即时通讯的框架》
《socket.io和websocket 之间是什么关系有什么区别》

有关SSE的详细介绍文章请参见
《SSE技术详解一种全新的HTML5服务器推送事件技术》

更多WEB端即时通讯文章请见
http://www.52im.net/forum.php?mod=collection&action=view&ctid=15

作者Jack Jiang (点击作者姓名进入Github)

欢迎访问肖海鹏老师的课程中心:http://edu.51cto.com/lecturer/user_id-10053053.html
欢迎加入肖海鹏老师技术交流群:2641394058(QQ)