HTML5 Web Socket通信

Published on 2016 - 09 - 25

服务器发送事件

使用XMLHttpRequest可以向Web服务器发送请求,并且能很快得到响应。这种通信方式是一对一的,即Web服务器响应之后,通信就结束了。换句话说,Web服务器不可能等几分钟,等有了更新之后再发送一次响应。

不过,有一些网页可以与Web服务器保持长期的联系。比如,显示股票报价的Google Finance(http://www.google.com/finance)。在桌面上打开这个页面不用管它,股票价格也会定期更新。再比如,英国广播公司(BBS)的滚动新闻页面http://www.bbc.co.uk/news/。呆在这个页面上一天,你也会发现新闻标题自动更新。当然,在一些Web邮件程序里(比如Hotmail,www.hotmail.com)也能发现收件箱会不断增加新邮件。

以上例子中的网页使用了一种技术,叫做轮询。顾名思义,轮询就是每隔一定时间(比如几分钟)就向Web服务器请求新数据。为了实现这个操作,可以使用JavaScript的setInterval()或setTimeout()函数,每过设定的时间就触发一次代码。

轮询是一个合理的方案,但有时候效率不高。因为轮询意味着要向服务器发送请求,要建立新连接,而这样做只是想知道是否有新数据。如果成千上万的用户都这样轮询,无疑会给服务器造成无谓的压力。

还有一种方案,叫做服务器发送事件(server-sent event),可以让网页与Web服务器保持连接。服务器任何时候都可以发送消息,而不必频繁断开连接,然后再重新连接并重新运行服务器端脚本。(除非你希望如此,因为服务器发送事件也支持轮询。)最关键的是,使用服务器发送事件很简单,大多数Web主机都支持,而且极其稳定可靠。如表1所示。

要求 IE Firefox Chrome Safari Opera Safari iOS Android
最低版本 6 5 5 11 4

接下来几节,我们将通过一个简单的例子演示服务器发送事件。

消息格式

与使用XMLHttpRequest不同,服务器发送事件这个标准不允许随意发送数据,而是必须遵循一个简单但明确的格式。每条消息必须以“data:”开头,然后是实际的消息内容,再加上换行符(PHP等很多编程语言中用“\n\n”表示换行符)。

下面就是一条标准的通过因特网传输的消息:

data: The web server has sent you this message.\n\n

也可以把一条消息分成多行,每行都要跟一个行结束符,用“\n”表示。这样就可以发送较复杂的内容了:

data: The web server has sent you this message.\n
data: Hope you enjoy it.\n\n

不过,一条消息分成多行,每行开头仍要有“data:”,而整个消息结束同样要跟“\n\n”。
利用这个格式,甚至可以发送JSON数据,这样网页只需一步即可把文本转换为JavaScript对象:

data: {\n
data: "messageType": "statusUpdate",\n
data: "messageData": "Work in Progress"\n
data: }\n\n

除了消息本身之外,Web服务器还可以发送唯一的ID值(使用 “id:” 前缀)和一个连接超时选项(使用“retry:”前缀):

id: 495\n
retry: 15000\n
data: The web server has sent you this message.\n\n

你的网页一般只关注消息本身,不关心ID和连接超时信息。ID和超时信息是浏览器要使用的。比如,浏览器在读到以上信息后就会知道,如果连接已经断开,那么应该在15 000毫秒(15秒)后再重新建立连接。重新建立连接时,应该把ID编号495一起发给服务器,以便确认。

注意 浏览器与服务器失去联系的原因有很多,比如网络中断或者代理服务器等待数据超时。浏览器会在可能的情况下自动重新打开连接,默认等待时间为3秒钟。

通过服务器脚本发送消息

知道了消息的格式,编写服务器端脚本发送消息就不在话下了。同样,我们还是以几乎所有Web主机都支持的PHP来写一个直观的例子。图4展示了一个从服务器取得消息的页面,而消息的内容只是服务器上的当前时间。

页面在监听的情况下,会连续不断地收到服务器发送的消息,大约每两秒钟一条。每条消息都会在上面的消息框中依次列出,消息框下方的时间表示接收最后一条消息的时间

这个例子的服务器端代码只是简单地过两秒钟就返回一次时间,以下是完整的代码:

<?php
  header("Content-Type: text/event-stream");
  header('Cache-Control: no-cache');

  //开始不间断的循环
  do {
    //取得当前时间
    $currentTime = date("h:i:s", time());

    //把时间放到消息中发送
    echo "data: " . $currentTime . PHP_EOL;
    echo PHP_EOL;
    flush();

    //等两秒钟再创建新消息
    sleep(2);
  } while(true);
?>

脚本开始先设置了两个重要的头部信息。首先,设置了MIME类型为text/event-stream,这是服务器端事件标准规定的:

header("Content-Type: text/event-stream");

然后,告诉Web服务器(及代理服务器)关闭Webt缓存。否则,含有时间的消息可能不会按先后次序到达:

header('Cache-Control: no-cache');

剩下的代码构成了一个无限循环(至少在客户端存在的情况下会一直循环下去)。每次循环都会调用内置的time()函数,取得当前时间(格式为hh:mm:ss),并将其保存在一个变量中:

$currentTime = date("h:i:s", time());

接下来,循环利用这个变量按照正确的格式来构建一条消息,以便使用PHP的echo命令发送。这个例子中的消息只有一行,以“data:”开头,然后是时间。消息字符串的最后是一个常量PHP_EOL(在PHP中表示end of line,即行结束符),也就是我们前面讨论的“\n”字符:

echo "data: " . $currentTime . PHP_EOL;
echo PHP_EOL;

调用flush()函数的用意是立即发送数据,而不是先缓冲起来,等到PHP代码执行完毕再发送。最后,sleep()函数会让程序暂停两秒钟,然后再继续下一次循环。

提示 如果接收两条消息会等待较长的时候,可能是连接被某个代理服务器(位于Web服务器与客户机之间,用于分散流量的服务器)给切断了。为了避免这种情况发生,可以每隔15秒左右,发送一条只包含冒号(:)而没有文本内容的注释消息。

在网页中处理消息

监听服务器发送消息的网页更简单。以下就是网页<body>部分的所有标记,分为三个<div>区块:一个用于滚动显示消息、一个用于显示时间,另一个用于显示按钮:

<div id="messageLog"></div>
<div id="timeDisplay"></div>
<div id="controls">
  <button onclick="startListening()">Start Listening</button><br>
  <button onclick="stopListening()">Stop Listening</button>
</div>

页面加载后,JavaScript代码会找到ID为messageLog和timeDisplay的<div>元素,将它们保存在全局变量中,以便后面的函数使用:

var messageLog;
var timeDisplay;

window.onload = function() {
  messageLog = document.getElementById("messageLog");
  timeDisplay = document.getElementById("timeDisplay");
};

监听事件的过程在用户单击Start Listening按钮的时候开始。此时,JavaScript会创建一个新EventSource对象,传入服务器端发送消息的脚本URL。(在这个例子中,脚本名为TimeEvents.php。)然后,将处理函数指定给onMessage事件,这个事件会在页面接收到消息时触发:

var source;

function startListening() {
  source = new EventSource("TimeEvents.php");
  source.onmessage = receiveMessage;
  messageLog.innerHTML += "<br>" + "Started listening for messages.";
}

提示 为了检测浏览器是否支持服务器端事件,可以测试是否存在window.EventSource属性。如果不存在,就要使用后备方案。比如,可以使用XMLHttpRequest对象定时向Web服务器请求数据。

在触发了receiveMessage函数时,可以从事件对象的data属性中取得消息。对我们这个例子而言,会把新消息显示在原来的消息框中,然后更新时间显示:

function receiveMessage(e) {
  messageLog.innerHTML += "<br>" + "New web server time received: " + e.data;
  timeDisplay.innerHTML = e.data;
}

注意,网页接收到的消息不会包含前缀“data:”和结束的“\n\n”符号,只有其中的消息内容(也就是时间值本身)。

最后,调用EventSource对象的close()方法,可以让页面停止监听服务器事件,相应的代码如下:

function stopListening() {
  source.close();
  messageLog.innerHTML += "<br>" + "No longer listening for messages.";
}

轮询服务器端事件

前面的例子以最简单的方式使用了服务器端事件:页面发送请求,连接保持打开,服务器定时发送信息。在当前连接有问题或者出于其他目的(比如手机电池快没电了)临时终止了通信时,浏览器可能需要重新连接(重新连接也是自动的)。

如果服务器脚本结束了,而且服务器关闭了连接怎么办?这个就有意思了,因为即便服务器有意关闭连接,网页仍然会自动重新打开连接(默认等待3秒钟),再次请求脚本,然后从头开始。

这种机制是可以利用的。比如,假设你写了一个比较短的服务器脚本,只发送一条消息。而此时网页就像在使用轮询(参见前几页),周期性地重新建立连接。唯一的差别就是Web服务器会告诉浏览器再等待多长时间才能检查新数据。在真正使用轮询的网页中,等待时间是在JavaScript代码中确定的。

下面这段脚本混合了两种手段,它保持连接(并周期性地发送消息)1分钟,并建议浏览器等待2分钟再重新连接,然后关闭连接:

<?php
  header("Content-Type: text/event-stream");
  header('Cache-Control: no-cache');

  //告诉浏览器在连接关闭后
  //等待2分钟再重新连接
  echo "retry: 120000" . PHP_EOL;

  //保存开始时间
  $startTime = time();

  do {
    //发送消息
    $currentTime = date("h:i:s", time());
    echo "data: " . $currentTime . PHP_EOL;
    echo PHP_EOL;
    flush();

    //如果过了1分钟,结束脚本
    if ((time() - $startTime) > 60) {
      die();
    }

    //等5秒钟,发送新消息
    sleep(5);
  } while(true);
?>

这样,等再运行脚本时,就可以做到用1分钟时间来更新,然后暂停服务2分钟(参见图5)。对于较复杂的例子而言,或许需要Web服务器向浏览器发送一条特殊的消息,告诉它不必等待数据更新(比如股市今天已经停止交易了)。此时,网页就可以调用EventSource的close()方法。

这个页面使用了消息流(在1分钟时间内发送一批消息),然后使用轮询(等待2分钟)。这种方案有助于减少Web服务器的流量,但要考虑数据更新的频率及保持数据最新的必要程度

注意 对于复杂的服务器脚本,浏览器的自动重新连接行为有时候并不好对付。比如,Web服务器可能会在执行某个任务期间就把连接断开。这种情况下,Web服务器代码会给每个客户端发送一个ID,以便重新连接时再把这个ID发给服务器。可是,服务器端代码必须负责生成ID、记录每个ID的操作(比如把某些数据保存到数据库里),以及在停止处进行恢复等。所有这些都需要你具有丰富的编码经验。

Web Socket

服务器发送事件非常适合从服务器连续不断地接收消息。但整个通信完全是单向的,无法知道浏览器是否响应,也不能进行更复杂的对话。

如果你想创建一个应用,浏览器与服务器需要正式对话,那你很可能使用XMLHttpRequest对象(而不用Flash)。使用XMLHttpRequest对象在很多情况下没有问题,但同样也有很多情况不合适。首先,XMLHttpRequest不适合快速地来回发送多条消息(比如,聊天室)。其次,没有办法将一次调用与下一次调用联系起来,每次网页发送请求,服务器都要确定请求来自何方。在这种情况下,要想把一系列请求关联起来,服务器端代码会变得非常复杂。

有一个方案能解决这个问题,但目前还没有得到所有浏览器支持。这个方案就是Web Socket标准。根据这个标准,浏览器能够保持对Web服务器打开的连接,从而与服务器长时间交换数据。Web Socket标准让开发人员非常兴奋,但目前尚未完全定案,而且支持它的浏览器也很少。

注意 目前,最好是在Chrome中测试Web Socket页面,因为它对Web Socket的支持一直是最好的。除了Chrome,也可以在Firefox 6 beta版及以上版本中测试。(尽管通过隐藏设置可以把浏览器对Socket的支持切换到旧版本,但却非常麻烦,而且只能使用过时的版本;所以,真的没有必要这么做。)

访问Web Socket

看到这里,想必读者知道对那些还没有制定完成,或者还没有被完全实现的新功能,是不用过分担心的。真正的问题在于,现在需要花多少时间去学习Web Socket,或者说,它到底是不是未来Web编程中的一个有价值的功能,以及未来一到两年内,你会不会在自己的应用中使用Web Socket?

要回答这些问题,必须理解两点。

  • 第一,Web Socket是一种专用手段,非常适合开发聊天室、大型多人游戏,或者端到端的协作工具。利用它能开发出很多新应用,但恐怕不太适合今天JavaScript驱动的Web应用(比如电子商务站点)。
  • 第二,Web Socket方案做起来可能会无比复杂。网页中的JavaScript很简单,可服务器端代码不好写,为此必须熟练掌握编程技能,而且要对多线程和网络模型有深刻理解。

为了使用Web Socket,需要在Web服务器上运行一个程序(也叫Web Socket 服务器)。这个程序负责协调各方通信,而且启动后就会不间断地运行下去。

注意 很多Web主机不允许长时间运行程序,除非你购买的是专用服务器(只分配给你的网站使用,不与他人共享)。如果你使用的是共享主机,那很可能无法创建使用Web Socket的网页。就算你能启动Web Socket服务器,让它不间断运行,Web主机商会检测它并把它关掉。

为了让读者了解Web Socket服务器是干什么的,下面列出了与它有关的一些任务。

  • 设置消息“词汇表”,即确定哪些消息有效,这些消息有什么含义。

    • 记录当前连接的所有客户端。
    • 检测向客户端发送消息是否出错,如果客户端已经停止响应,则终止与该客户通信。
    • 处理内存数据,也就是所有Web客户端都可以访问的数据。可能涉及很多微妙的问题,比如一个客户端要加入,而另一个客户端要退出,这两个客户端的连接都保存在同一个内存对象中。

多数开发人员都没有利用Socket创建过服务器端程序,因为这样做明显代价太大。最简单的办法是安装别人写好的Web Socket服务器,然后再设计网页来与之通信。Web Socket标准的JavaScript部分使用起来很容易,不会有什么问题。而另一个部分可以采取别人写好的Socket服务器代码,根据需要稍加改动即可。目前,有很多现成的Web Socket服务器项目,其中不乏开源和免费的。这些项目致力于实现各种功能,也支持很多种服务器端编程语言。

简单的Web Socket客户端

从网页的角度看,Web Socket很容易理解,也很容易使用。第一步就是创建WebSocket对象,传入一个URL,比如:

var socket = new WebSocket("ws://localhost/socketServer.php");

这个URL以ws://开头,是一个表示Web Socket连接的新协议。不过,URL仍然指向服务器上的一个Web应用(即这里的socketServer.php脚本)。Web Socket标准还支持以wss://开头的URL,表示安全、加密的连接(这跟请求网页时使用https://而不是http://一样)。

注意 Web Socket并不仅限于连接自己的Web服务器。你的网页可以打开一个到其他Web服务器的Web Socket连接,而不会增加任何工作量。

创建WebSocket对象后,页面就会尝试连接服务器。下一步,就是使用WebSocket对象的4个事件:onOpen、onError、onClose和onMessage。其中,onOpen会在建立连接后触发,onError会在出现问题时触发,onClose会在连接关闭时触发,而onMessage会在页面从服务器接收到消息时触发。利用这些事件,就可以实现与Web Socket服务器的通信:

socket.onopen = connectionOpen;
socket.onmessage = messageReceived;
socket.onerror = errorOccurred;
socket.onopen = connectionClosed;

比如,连接成功后,需要向服务器发送一条消息。发送消息要使用WebSocket的send()方法,这个方法接收纯文本内容作为参数。下面就是处理onOpen事件并发送消息的函数:

function connectionOpen() {
  socket.send("UserName:jerryCradivo23@gmail.com");
}

可想而知,服务器在收到这条消息后,会发回一条新消息。

利用onError或onClose事件,可以向用户发出通知。不过,最重要的事件还是onMessage,每当Web服务器发来新数据时,都会触发这个事件。同样,响应这个事件的JavaScript代码也非常好理解,主要是从事件对象的data属性中取得消息内容:

function messageReceived(e) {
  messageLog.innerHTML += "<br>" + "Message received: " + e.data;
}

如果网页认为通信可以结束了,可以调用disconnect()方法关闭连接:

socket.disconnect();

经过以上简单介绍,我们知道使用其他人写好的Web Socket服务器并不费事,只要知道发送什么消息,以及服务器会发回什么消息即可。

注意 建立Web Socket连接时,后台其实会执行很多处理工作。首先,网页要使用常见的HTTP标准与服务器建立联系,然后再把连接“升级”到Web Socket连接,以便浏览器与服务器能够双向通信。此时,如果计算机与服务器之间有代理服务器(比如在公司网络中),可能会遇到问题。代理服务器可能会拒绝请求并断开连接。对于这种问题,可以检测失败的连接(通过WebSocket对象的onError事件),然后使用http://tinyurl.com/polyfills中的Socket“腻子脚本”来作为后备。这些脚本会使用轮询来模拟Web Socket连接。

使用现成的Web Socket服务器

你想自己试试Web Socket?没问题,网上有很多现在的Web Socket服务器可供你搭建试验环境。比如,可以先试试http://www.websocket.org/echo.html,这个页面提供了一个最基本的Web Socket服务器:你向它发送消息,它再返回同样的消息(参见图6)。虽然这谈不上有意思,但却能让我们熟悉WebSocket对象的所有功能。事实上,无论你的网页保存在哪里(可以在你自己的Web服务器上,也可以在你的本地计算机硬盘上),都可以连接到这个Web Socket服务器。

假如你还不满足,可以再看看下面这两个更有意思的例子。

  • 简单的聊天程序。这是一个任何人都可以免费使用的聊天工具,在上面发送的任何消息,都能立即被所有人看到。它的地址是http://html5demos.com/web-socket。

    • 多人绘图板。这个页面使用了Web Socket和HTML5 Canvas。其他人在画布上作画时,你的画布上能够实时显示他们的笔迹(反之亦然)。这个想法虽然简单,但真正看在眼里,还是挺让人震惊的。试一试:http://mrdoob.com/projects/multiuserpad/。

参考文档