什么是WebSocket呢?
WebSocket是HTML5新增的一种通信协议,目标主流的浏览器都支持这个协议,比如Google的Chrome、Apple的Safari、Mozala的Firefox、Microsoft的IE等。对WebSocket协议支持最早的当属Chrome浏览器,从Chrome12开始就已经开始支持,随着协议草案不断完善,各个浏览器对协议的实现也在不停的更新。
为什么会引入WebSocket协议呢?
浏览器已经支持HTTP协议了,为什么还要开发一种新的WebSocket协议呢?因为HTTP协议是一种单向的网络协议,在建立连接后只允许浏览器或用户代理(UserAgent
)向Web服务器发出请求资源后,Web服务器才能返回相应的资源数据。而Web服务器是不能够主动推送数据给浏览器的,HTTP设计之初的考虑到安全问题,如果Web服务器能够主动的推送数据给浏览器,那么浏览器就太容易受到攻击,一些广告商也会主动的将广告信息在不经意间强行推送给用户,这不能不说是一个灾难。但是单向的HTTP协议给现代的网站和Web应用程序却带来了许多问题。加入要开发一个基于Web的应用程序去获取当前Web服务器的实时数据的话,比如股票的实时行情,火车票的剩余票数等,这个时候就需要浏览器与Web服务器之间反复的进行HTTP通信,浏览器需要不断地发送请求去获取实时数据。
实时获取Web服务器资源的方式有哪几种呢?
那么在还没有WebSocket协议之前,有哪几种方式可以实时的获取Web服务器上的资源数据呢?
- 短轮询
Polling
Polling的方式是通过浏览器定时向Web服务器发送HTTP请求,Web服务器接收到请求后会将最新的数据返还给浏览器。浏览器得到数据后将其渲染显示,然后再定期的重复这一过程。
Polling的方式虽然能够满足实时的需求,但存在一定的问题,比如在某段时间内Web服务器没有数据更新呢,此时浏览器仍然需要定时发送请求过来询问,Web服务器会将以前的老数据再次传送过去,浏览器将这些没有变化的数据又渲染显示出来。这样既浪费了网络带宽,又浪费了CPU的利用利率。如果将浏览器发送请求的时间周期调大一些,虽然可以缓解这一问题,但如果在Web服务器上数据更新很快时,又将无法保证Web应用程序获取数据的实时性。
针对这种情况,Polling做出改进而衍生出来Long Polling。
- 长轮询
Long Polling
Long Polling的操作是这样的:浏览器发送请求到Web服务器时,Web服务器可以做两件事情。第一件事是如果服务器数据更新就会立即将数据发回给浏览器,了浏览器接收到数据后再理解发送请求给Web服务器。第二件事是如果服务器没有数据更新,此时与Polling不同的是Web服务器不会立即发送回应信息给浏览器,而会见这个请求保持住,等到有数据更新时,再来响应这个请求。当然,如果服务器的数据长期没有更新的话,一段时间后,这些请求就会超时,浏览器将会收到超时消息,当浏览器收到超时消息又会立即发送一个新的请求给Web服务器,然后依次循环这个过程。
Long Polling的方式虽然在某种程度上减小了网络带宽和CPU的利用率等问题,但仍存在缺陷,比如Web服务器的数据更新速度较快,当Web服务器在传送一个数据包给浏览器后,必须等待浏览器的下一个请求的到来才能传递第二给更新的数据包给浏览器。这样的话,浏览器显示的实时数据最快的时间也就是2 x RTT(往返时间)。另外,由于HTTP数据包的头部数据量往往会很大,一般有400多字节,但是真正被服务器使用的却很少,有时只有10字节左右,这样的数据包在网络上周期性的传输,难免对网络带宽又是一种浪费。
实际上Long Polling长轮询的底层实现是在服务器的程序中加入一个死循环,在循环中检测数据的变化,当发现有幸数据时会立即将其输出给浏览器并断开连接,浏览器收到数据后会再次发起请求进入下一个周期。
长轮询的弊端是服务器长时间连接会消耗服务器资源,另外返回的数据的顺序无法保证,难以管理和维护。
对于长轮询的处理,服务器并不会一直保持,通常的做法是会设置一个最大时限,可以通过心跳包的方式,设置多少秒之后没有接收到心跳包就关闭当前连接。
通过以上的分析可知,要想在浏览器上支持双向通信而且协议的头部又不是那么的庞大,不得不采用新的协议,WebSocket也就是为了解决这个问题而设计诞生的。
WebSocket协议是什么样的呢?
WebSocket协议是一种双向的通信协议,它建立在TCP之上,同HTTP一样是通过TCP来传递数据的,不过它与HTTP最大的不同点在于:
- WebSocket是一种双向通信协议,在建立连接后,WebSocket服务器和浏览器之间都能主动地向对象发送或接收数据,这就像Socket一样,只是与之不同的是,WebSocket是一种建立在Web基础上的简单模拟Socket的协议。
- WebSocket需要通过握手建立连接,类似于TCP也需要客户端和服务端进行握手成功后才能互相通信。
这里简要的说明一下WebSocket握手的过程,当Web应用程序调用new WebSocket(url)
接口时,浏览器就会开始与对应URL地址的WebSocket服务器建立握手的连接。具体的过程是这样的:
首先,浏览器与WebSocket服务器之间通过TCP的三次握手建立连接,如果连接建立失败则后续流程将不再执行,此时Web应用程序将会收到错误消息通知。
当TCP连接建立成功后,浏览器会通过HTTP发送WebSocket所支持的版本号、协议的字版本号、原始地址、主机地址等一系列字段给WebSocket服务器。
例如:握手请求
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat,superchat
Sec-WebSocket-Version: 13
这里需要重点关注的是Sec-WebSocket-Key
这个字段,它又称为“梦幻字符串”也是一个密钥,其值采用base64
编码的随机16字节长的字符序列,通过这个密钥服务器才能解码辨认是否为WebSocket握手请求,如果比对辨认成功则认为此协议是WebSocket协议,否则则认为是普通的HTTP协议。
- 当WebSocket服务器接收到浏览器发送过来的握手请求后,如果数据包的数据以及格式正确、客户端和服务器的协议版本号匹配的话,就会接受本次握手连接,并给出相应的数据回复,同时回复的数据包也会采用HTTP协议进行传输。
例如:握手响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
在响应头中同样存在的一个“梦幻字段”,不过它的名字叫做Sec-WebSocket-Accept
,同样也是一个密钥,不同的是这个字符串是要让客户端辨认,当客户端拿到自动解码后,会辨认是否是一个WebSocket握手响应。
- 当浏览器接收到WebSocket服务器回复的数据包后,如果数据包内容、格式正确的话,就表示本次连接建立成功,浏览器会触发
onopen
消息,此时Web开发人员就可以在此通过WebSocket接口中的send
方法向WebSocket服务器发送数据了。否则握手建立失败,Web应用程序将收到onerror
的消息,并能够知道握手连接失败的原因。
简单来说WebSocket的操作流程是:客户端首先向服务器发起一次特殊的HTTP请求,服务器接收后开始辨认请求头如果是客户端的请求则开始进行普通的TCP三次握手建立建立,否则将会按照普通的HTTP请求进行处理。
WebSocket提供了两种数据传输,一种是文本格式的数据,另一种则是二进制格式的数据。
WebSocket与HTTP和TCP有什么关系呢?
了解完WebSocket协议的工作原理后,需要弄清楚一点的是WebSocket与TCP和HTTP之间的关系是什么样子的呢?
WebSocket与HTTP协议一样都是基于TCP的,所以它们都是可靠的协议,Web开发者调用WebSocket的send
方法,在浏览器的实现最终都是通过TCP的接口进行传输的。
WebSocket和HTTP协议一样都属于应用层的协议,WebSocket在建立握手连接时,数据是通过HTTP协议传输的,因此会采用一部分HTTP的数据包的字段。但是在建立连接之后,真正的数据传输阶段就不需要HTTP参与了。
要想搭建WebSocket服务器,你需要明白的是WebSocket作为一种新的通信协议,目前还处于草案阶段并没有成为标准,市面上也没有成熟的WebSocket服务器或类库实现WebSocket协议,所以需要自己手动编写代码去解析和组装WebSocket的数据包。不过,也确实有部分的开源库可供我们使用,比如基于Python的PyWebSocket,基于Node.js的WebSocket-Node等,这些类库文件已经实现了WebSocket数据包的封装和解析,可以直接调用这些接口来开发,这样很大程度上减少了工作量。
什么是WebSocket服务器呢?
-
swoole_websocket_server
是在swoole_http_server
的基础上增加了对WebSocket协议的解析 - 完整的WebSocket协议请求会被解析并封装在
frame
对象内 -
swoole_websocket_server
新增了push
方法用于发送WebSocket数据
WebSocket服务器简单来说就是一个遵循特殊协议监听服务器任意端口的TCP应用,一个WebSocket服务器可以使用任意的服务器编程语言来实现,只要语言能够实现基本的Berkeley Sockets伯克利套接字。
WebSocket服务器通常是独立的服务器,因为负载均衡和其他原因,通常会使用反向代理如标准的HTTP服务器来发现WebSocket握手协议,预处理之后将客户端请求信息发送到真正的WebSocket服务器,这也就意味着WebSocket服务器不必充斥着Cookie和签名的处理方法,完全可以放在代理中解决。
在开发WebSocket服务器之前需要了解TCP的Socket编程知识。下面基于Swoole从客户端握手请求到服务端握手响应返回来阐述下TCP下Socket的工作原理。
在开始之前,需要解决的第一个问题是WebSocket的握手规则。首先,服务器必须使用标准的TCP的Socket来监听即将到来的Socket连接,由于WebSocket握手采用的是HTTP,因此在选择监听端口上一般会是HTTP的80端口或HTTPS的443端口,虽然服务器可以选择任意端口,但除此二者之外的端口可能会遇到防火墙或代理的问题。
WebSocket握手规则是什么呢?
接着,我们将WebSocket的握手规则划分为两个阶段来分析:
- 客户端握手请求
由于WebSocket握手过程是由客户端发起的,所以必须要明白服务器是如何解析客户端的请求,通常客户端会发送一个标准的HTTP请求。
类似于这样
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
对于客户端发起WebSocket握手请求中会存在标准HTTP头信息字段,另外还会包括WebSocket自定义的字段。因此在很多公共设置中,会有一个代理服务器来进行处理HTTP请求。如果有的header头信息不被识别或是非法值,服务器会发送类似400 Bad Request
并立即关闭Socket,通常会在HTTP返回的Body体中给出握手失败的原因。不过这些信息可能不会被浏览器所展示。如果服务器无法是被WebSocket的版本,通常会返回一个Sec-WebSocket-Version
的消息头,并会在其中指明自己能够接受的版本号。
浏览器一般会发送一个Origin Header的信息头,可使用这个Header头来做安全限制,也就是检查是否具有相同的Origin。如果不是期望的Origin将会返回了一个403 Forbidden
的错误信息。另外需要注意的是,在一些非浏览器的客户端中是可以伪造Origin的,而很多应用将会拒绝没有Origin消息头的请求。
请求资源定位符在规范中并没有给出明确的定义,所有有很多人在巧妙地使用它,比如让一个服务器处理多个WebSocket应用。
对于规范的HTTP code只可以在握手之前使用,当握手成功后应该使用不同的code集合。
服务器握手返回
当服务器接收到来自客户端的请求时,会发送一个相当奇怪的响应。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Swoole提供的WebSocket服务器是什么样的呢?
Swoole1.7.9版本开始内置了WebSocket服务器,方便快速的编写异步非阻塞多进程的WebSocket服务器。
案例:客户端通过浏览器的WebSocket协议向服务端发送请求,服务器接收到请求后先第三方接口请求获取数据并存入Redis。
$ mkdir test && cd test
$ vim server.php
创建服务器
<?php
//创建Redis连接
$host = "127.0.0.1";
$port = 6379;
$redis = new Redis();
$redis->connect($host, $port);
//创建websocket服务器
$host = "0.0.0.0";
$port = 9501;
$server = new swoole_websocket_server($host, $port);
//服务器监听websocket连接打开事件
$server->on("open", function($ws, $rq) use($redis){
//将连接标识保存到Redis的无序集合set中
$redis->sAdd("fd", $rq->fd);
});
//服务器监听websocket连接的消息事件
$server->on("message", function($ws, $frame) use($redis){
//获取二进制数据
$url = "http://imgsrc.baidu.com/imgad/pic/item/267f9e2f07082838b5168c32b299a9014c08f1f9.jpg";
$bin = file_get_contents($url);
//获取Redis中保存的连接
$fds = $redis->sMembers("fd");
if(count($fds) > 0){
foreach($fds as $fd){
//发送字符串
$ws->push($fd, $frame->fd.":".$frame->data);
//发送二进制
$ws->push($fd, $bin, WEBSOCKET_OPCODE_BINARY);
}
}
});
//服务器监听websoket连接关闭事件
$server->on("close", function($ws, $fd) use($redis){
$redis->sRem("fd", $fd);
});
//启动服务器
$server->start();
客户端
$ vim client.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script>
if(window.WebSocket)
{
var url = "ws://0.0.0.0:9501";
var ws = new WebSocket(url);
ws.onopen = function(event){
console.log("client websocket open success");
ws.send("hello server");
};
ws.onmessage = function(event)
{
console.log(event);
};
}
</script>
</body>
</html>
案例:
服务器
$ vim server.php
<?php
/**WebSocket服务端*/
//创建异步WebSocket服务端对象
$host = "0.0.0.0";
$port = 9501;
$server = new swoole_websocket_server($host, $port);
echo "[server] ".json_encode($server).PHP_EOL;
//监听客户端握手
$server->on("open", function(swoole_websocket_server $server, $request){
echo PHP_EOL;
$fd = $request->fd;//获取客户端请求的文件描述符
echo "[open] client {$fd} handshake success".PHP_EOL;
echo "[server] ".json_encode($server).PHP_EOL;
echo "[request] ".json_encode($request).PHP_EOL;
});
//监听客户端发送的消息
$server->on("message", function(swoole_websocket_server $server, $frame){
echo PHP_EOL;
$fd = $frame->fd;//获取客户端请求的文件描述符
$data = $frame->data;//获取客户端发送的消息
echo "[message] client {$fd} : {$data}".PHP_EOL;
echo "[server] ".json_encode($server).PHP_EOL;
echo "[frame] ".json_encode($frame).PHP_EOL;
$message = "success";
$server->push($fd, $message);
});
//监听客户端断开连接
$server->on("close", function(swoole_websocket_server $server, $fd){
echo PHP_EOL;
echo "[close] client {$fd}".PHP_EOL;
echo "[server] ".json_encode($server).PHP_EOL;
echo "[fd] ".json_encode($fd).PHP_EOL;
});
//启动服务器
$server->start();
客户端
$ vim client.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script src="./client.js"></script>
</body>
</html>
$ vim client.js
var host = "127.0.0.1";
var port = 9501;
var address = "ws://"+host+":"+port;
var ws = new WebSocket(address);
ws.onopen = function()
{
console.log("[onopen]");
var message = "onopen";
ws.send(message);
}
ws.onmessage = function(evt)
{
console.log("[onmessage]", evt);
var data = evt.data;
console.log(data);
}
ws.onclose = function(evt)
{
console.log("[onclose]", evt);
}
ws.onerror = function(evt)
{
console.log("[onerror]", evt);
}
var message = "hello";
ws.send(message);
ws.close();
Swoole的事件包括哪几种呢?
WebSocket除了能够接收Swoole\Server
和Swoole\Http\Server
基类的回调函数外,额外增加了三个回调函数设置。
-
onHandleShake
可选,WebSocket建立连接后进行握手。 -
onOpen
可选,当WebSocket客户端与服务器建立连接并完成握手会触发回调。 -
onMessge
必选,当服务器收到客户端数据帧时触发回调。
onReuqest
WebSocket
服务器继承自HTTP
服务器,如果是WebSocket服务器设置了onRequest
回调,那么也可以 同时将WebSocket服务器作为HTTP服务器使用。如果没有设置onRequest
回调,WebSocket收到HTTP请求后会返回400错误页面。如果想要通过接收HTTP触发所有WebSocket的推送,想要注意作用域的问题,如果是面向过程的需要使用到global
。对于W ebSocket服务器进行引用,面向对象可以将WebSocket/Server
设置成一个成员属性。
例如:面向过程的方式
<?php
$host = "0.0.0.0";
$port = 9501;
$server = new swoole_websocket_server($host, $port);
$server->on("open", function(swoole_websocket_server $server, $request){
$fd = $request->fd;//客户端连接标识
echo "[open] server handshake success with client {$fd}".PHP_EOL;
});
$server->on("message", function(swoole_websocket_server $server, $frame){
$fd = $frame->fd;
$opcode = $frame->opcode;
$finish = $frame->finish;
echo "[message] receive from client {$fd}, opcode {$opcode}, finish {$finish}".PHP_EOL;
$message = "success ";
$server->push($fd, $message);
});
$server->on("close", function(swoole_websocket_server $server, $fd)
{
echo "[close] client {$fd}".PHP_EOL;
});
$server->on("request", function(swoole_http_request $request, swoole_http_response $response) use($server){
//遍历所有WebSocket连接用户的fd,给所有用户推送
foreach($server->connections as $fd)
{
//判断是否是正确的WebSocket连接,若非则有可能会push失败。
if($server->isEstablished($fd)){
$message = $request->get["message"];
$server->push($fd, $message);
}
}
});
$server->start();
onHandShake
WebSocket建立连接后进行握手,WebSocket服务器已经内置了handshake
,如果用户希望自己进行握手处理,可设置onHandShake
事件回调函数。
函数原型
function onHandShake(
swoole_http_request $request,
swoole_http_response $response
)
参数列表
-
onHandShake
事件回调是可选的 - 设置
onHandShake
回调函数后不会再触发onOpen
事件,需要应用程序自行处理。 -
onHandShake
中必须调用$response->status
设置状态吗为101并调用end
响应,否则会握手失败。 - Swoole内置的握手协议为
Sec-WebSocket-Version:13
,低版本浏览器需要自行实现握手。 - Swoole1.8.1或更高版本可以使用
$server->defer
调用onOpen
逻辑
需要注意的是,仅仅需要自行处理handshake
握手的时候再设置onHandShake
回调函数,如果不需要自定义握手过程,就不要设置该回调,使用Swoole默认的握手即可。
onOpen
当WebSocket客户端与服务器建立连接并完成握手之后会回调onOpen
函数。
onOpen
事件函数是可选的,可以调用push
方法向客户端发送数据或调用close
关闭连接。
函数原型
function onOpen(
swoole_websocket_server $server,
swoole_http_request $request
)
参数列表
-
swoole_http_request $request
是一个HTTP请求对象,包含了客户端发送过来的握手请求信息。
onMessage
当服务器接收到来自客户端的数据帧frame
时会触发并回调onMessage
函数。
onMessage
回调必须被设置,如果未设置服务器将无法启动,客户端发送的ping
帧是不会触发onMessage
回调函数的,底层会自动回复pong
包。
函数原型
function onMessage(
swoole_websocket_server $server,
swoole_websocket_frame $frame
)
参数列表
-
swoole_websocket_frame $frame
是swoole_websocket_frame
对象,包含了客户端发送过来的数据帧信息。
数据帧
swoole_websocket_frame $frame
包含四个属性
-
$frame->fd
表示客户端的socket_id
,使用$server->push
推送数据时需要使用。 -
$frame->data
表示数据内容,可以是文本内容也可以是二进制数据,可以通过opcode
值来判断。如果$data
是文本类型,编码格式必须是UTF-8
,这是WebSocket协议标准文档所规定的。 -
$frame->opcode
表示WebSocket的OpCode类型 -
$frame->finish
表示数据帧是否完整,一个WebSocket请求可能会分成多个数据帧进行发送,Swoole底层已经实现了自动合并数据帧,不用担心接收到的数据帧不完整。
OpCode
WebSocket的OpCode数据类型分为两种
-
WEBSOCKET_OPCODE_TEXT = 0x1
表示文本数据 -
WEBSOCKET_OPCODE_BINARY = 0x2
表示二进制数据
Swoole WebSocket 提供的函数有哪些呢?
数据推送push
push
方法用于Swoole的WebSocket服务器向WebSocket客户端连接推送数据,推送的数据长度最大不得超过2MB。
push
方法仅适用于Swoole1.7.11+版本
函数原型
function WebSocket\Server->push(
int $fd,
$data,
int $opcode = 1,
bool $finish = true
)
参数模式1
-
int $fd
表示客户端连接ID,如果指定的$fd
对应的TCP连接并非WebSocket客户端,将会发送失败。 -
$data
需要发送的数据内容 -
int $opcode
表示指定发送数据内容的格式,默认为文本WEBSOCKET_OPCODE_TEXT
,可发送二进制内容WEBSOCKET_OPCODE_BINARY
。
发送成功时会返回true
,发送失败则返回false
。
参数模式2
参数模式2仅适用于Swoole4.2.0+版本,其中仅包含一个参数$data
,可以传入一个swoole_websocket_frame
数据帧对象,支持发送各种帧类型。
案例:服务器监听MySQL数据库是否有更改,若有更改则主动告知并将更新数据推送给WebSocket客户端。
案例:接收来自客户端的请求