## **WebSocket概述**
WebSocket是HTML5规范提出的一种应用层协议(建立在TCP协议之上,目前除了完犊子的IE浏览器,其他浏览器都基本支持),它是Web 客户端与服务器之间实现全双工通信的标准。既然是全双工,就意味着不是HTTP协议那种只能从客户端向服务器发起请求的单向通信,服务端在必要的时候也可以推送信息到客户端,而不是被动接收客户端请求再返回响应。
### **WebSocket的运行机制**
Websocket是基于HTTP协议的,或者说借用了HTTP的协议来完成一部分握手,它对应的 scheme 是 ws,加密的 WebSocket 对应的 scheme 就是 wss,域名、端口、路径、参数则和 HTTP 协议的 URL一样(`ws://example.com:80/some/path`)。WebSocket请求响应客户端服务器交互过程如下:
1. 请求阶段:WebSocket 复用了 HTTP 的握手通道,要建立 WebSocket 通信,需要在连接发起方的 HTTP 请求报文中通过 Upgrade 字段告知服务器通信协议升级到 websocket,然后通过 Sec-WebSocket-\* 扩展字段提供 WebSocket 的协议、版本、键值等信息:
![](https://img.kancloud.cn/ba/48/ba488a1ade55bfbcbb5b57b61b6b8cf3_638x396.png)
2. 响应阶段:对于上述握手请求,服务器会返回 101 Switching Protocols 响应表示协议升级成功:
![](https://img.kancloud.cn/35/26/3526ccadcde540ed32a0ca2e7d161525_928x288.png)
成功握手确立 WebSocket 连接后,后续通信就会使用 WebSocket 数据帧而不是 HTTP 数据帧——不同于HTTP报文的明文传输,WebSocket中所有发送数据使用帧【包含一个帧类型标识码,一个负载长度和负载内容】的形式发送。
下面是 WebSocket 通信的时序图:
![](https://img.kancloud.cn/1b/7d/1b7d553507fcf606f54b5ff38140dcd3_1276x902.png)
### **WebSocket的作用**
WebSocket的主要使用场景有:**1. 即时通讯**,比如网页QQ,聊天系统等;**2. 轮询**,比如web开发中有些功能(扫码登录?)需要通过轮询(比如每间隔5秒)去服务器读取数据。对于这种需经常推送实时数据到客户端的场景,以往的技术能力通常是采用ajax轮询、Comet(long poll)技术解决:
- ajax轮询:原理很简单,客户端隔个几秒就发送一次ajax请求,询问服务器是否有新信息。
- long poll:原理跟 ajax轮询 差不多,也采用轮询的方式,但采取的是阻塞模型——客户端向服务器发送请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求,周而复始。
从上面可以看出,这两种方式都是非常消耗资源的,ajax轮询 请求中有大半是无用,浪费带宽和服务器资源,对服务器的处理速度也有所要求,而long poll的服务器hold连接会消耗资源,对服务器的并发能力也有所要求。这都是因为HTTP协议是非持久化的、单向的、被动的网络协议。
WebSocket的出现可以弥补这一缺点。WebSocket只需要服务器和浏览器通过HTTP协议进行一个握手的动作,就可以单独建立一条全双工TCP的通信通道进行数据的传送。在客户端断开 WebSocket 连接或 Server 端断掉连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实时性优势明显。
> Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。相当于设计模式中的门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
## **WebSocket服务器**
PHP 异步网络通信引擎 Swoole 内置了对 WebSocket 的支持,通过几行 PHP 代码就可以写出一个异步非阻塞多进程的WebSocket服务器:
```
<?php
// 初始化 WebSocket 服务器,在本地监听 8000 端口
$server = new Swoole\WebSocket\Server("localhost", 8000);
// 建立连接时触发
$server->on('open', function (Swoole\WebSocket\Server $server, $request) {
echo "server: handshake success with fd{$request->fd}\n";
});
// 收到消息时触发推送
$server->on('message', function (Swoole\WebSocket\Server $server, $frame) {
echo "receive from {$frame->fd}:{$frame->data},opcode:{$frame->opcode},fin:{$frame->finish}\n";
$server->push($frame->fd, "this is server");
});
// 关闭 WebSocket 连接时触发
$server->on('close', function ($ser, $fd) {
echo "client {$fd} closed\n";
});
// 启动 WebSocket 服务器
$server->start();
```
编写完成后,将这段 PHP 代码保存到本地`websocket_server.php`文件。
## **WebSocket客户端**
在客户端,可以通过 JavaScript 调用浏览器内置的[WebSocket API](https://developer.mozilla.org/zh-CN/docs/Web/API/WebSockets_API)实现 WebSocket 客户端,实现代码和服务端差不多,无论服务端还是客户端 WebSocket 都是通过事件驱动的,我们在一个 HTML 文档中引入相应的 JavaScript 代码:
```
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chat Client</title>
</head>
<body>
<script>
window.onload = function () {
var nick = prompt("Enter your nickname");
var input = document.getElementById("input");
input.focus();
// 初始化客户端套接字并建立连接
var socket = new WebSocket("ws://localhost:8000");
// 连接建立时触发
socket.onopen = function (event) {
console.log("Connection open ...");
}
// 接收到服务端推送时执行
socket.onmessage = function (event) {
var msg = event.data;
var node = document.createTextNode(msg);
var div = document.createElement("div");
div.appendChild(node);
document.body.insertBefore(div, input);
input.scrollIntoView();
};
// 连接关闭时触发
socket.onclose = function (event) {
console.log("Connection closed ...");
}
input.onchange = function () {
var msg = nick + ": " + input.value;
// 将输入框变更信息通过 send 方法发送到服务器
socket.send(msg);
input.value = "";
};
}
</script>
<input id="input" style="width: 100%;">
</body>
</html>
```
将这个 HTML 文档命名为`websocket_client.html`。
## **WebSocket通信演示**
接下来,我们在命令行启动 WebSocket 服务器:
~~~
php websocket.php
~~~
然后在浏览器中访问`websocket_client.html`,首先会提示我们输入昵称,输入之后点击确定,JavaScript 代码会继续往下执行,让输入框获取焦点,然后初始化 WebSocket 客户端并连接到服务器,这个时候通过开发者工具可以看到 Console 标签页已经输出了连接已建立日志:
![](https://img.kancloud.cn/a8/ec/a8ecccb23f3922e912338012d697f1e3_1198x202.jpg)
在 Network 里面也可以看到 WebSocket 握手请求和响应:
![](https://img.kancloud.cn/12/88/128854ad58ae1ce42592b1105058974c_2208x1136.jpg)
这个时候我们在输入框中输入「你好,WebSocket!」并回车,即可触发客户端发送该数据到服务器,服务器接收到消息后会将其显示出来:
![](https://img.kancloud.cn/bb/3f/bb3f3ed2878d442b43a5a6bc19ad2d43_964x190.jpg)
同时将「This is server」消息推送给客户端,客户端通过`onmessage`回调函数将获取到的数据显示出来。在开发者工具的 Network->WS 标签页可以查看 WebSocket 通信细节:
![](https://img.kancloud.cn/43/8d/438da9020878fd3cdeb3de91454383e7_2476x620.jpg)看起来,这个过程还是客户端触发服务器执行推送操作,但实际上,在建立连接并获取到这个客户端的唯一标识后,后续服务端资源有更新的情况下,仍然可以通过这个标识主动将更新推送给客户端,而不需要客户端发起拉取请求。WebSocket 服务器和客户端在实际项目中的实现可能会更加复杂,但是基本原理是一致的。
## **在Laravel中集成Swoole实现WebSocket服务器**
我们将基于[LaravelS](https://github.com/hhxsv5/laravel-s)扩展包把 Swoole 集成到 Laravel 项目来实现 WebSocket 服务器,以便与客户端进行 WebSocket 通信从而实现广播功能。
### **创建WebSocketService类**
基于LaravelS扩展包实现WebSocket服务器,首先需要创建一个实现了`Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface`接口的`WebSocketService`类:
```
<?php
namespace App\Services;
use Hhxsv5\LaravelS\Swoole\WebSocketHandlerInterface;
use Illuminate\Support\Facades\Log;
use Swoole\Http\Request;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
class WebSocketService implements WebSocketHandlerInterface
{
public function __construct()
{
}
// 连接建立时触发
public function onOpen(Server $server, Request $request)
{
// 在触发 WebSocket 连接建立事件之前,Laravel 应用初始化的生命周期已经结束,你可以在这里获取 Laravel 请求和会话数据
// 调用 push 方法向客户端推送数据,fd 是客户端连接标识字段
Log::info('WebSocket 连接建立');
$server->push($request->fd, 'Welcome to WebSocket Server built on LaravelS');
}
// 收到消息时触发
public function onMessage(Server $server, Frame $frame)
{
// 调用 push 方法向客户端推送数据
$server->push($frame->fd, 'This is a message sent from WebSocket Server at ' . date('Y-m-d H:i:s'));
}
// 关闭连接时触发
public function onClose(Server $server, $fd, $reactorId)
{
Log::info('WebSocket 连接关闭');
}
}
```
在这个 WebSocket 服务器类中,需要实现接口中声明的方法,其实就是 WebSocket 通信事件的回调函数,和上例中的WebSocket服务器基本一致,只是通过类进行了封装而已。
### **修改配置文件**
接下来,打开配置文件`config/laravels.php`,启用 WebSocket 通信并将刚刚创建的服务器类配置到对应的配置项:
~~~
'websocket' => [
'enable' => true,
'handler' => \App\Services\WebSocketService::class,
],
~~~
我们还可以在`swoole`配置项中配置 WebSocket 长连接的强制关闭逻辑:
~~~
'swoole' => [
...
// 每隔 60s 检测一次所有连接,如果某个连接在 600s 内都没有发送任何数据,则关闭该连接
'heartbeat_idle_time' => 600,
'heartbeat_check_interval' => 60,
...
],
~~~
### **配置Nginx支持WebSocket**
由于 WebSocket 通信是基于 HTTP 协议的,所以,我们还要配置 HTTP 服务器来支持 WebSocket 请求,以 Nginx 为例,我们在[基于 Swoole 实现 HTTP 服务器一节](#)中添加的 Nginx 配置文件基础上进行 WebSocket 配置,为了与之前基于 PHP-FPM 作为进程管理器的站点配置区分开,创建一个新的站点配置`todoapp-s.conf`(基于待办任务项目进行测试),编辑配置文件内容如下:
```
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream laravels {
# Connect IP:Port
server 127.0.0.1 weight=5 max_fails=3 fail_timeout=30s;
keepalive 16;
}
server {
listen 80;
server_name todo-s.test;
root /var/www/todoapp/public;
error_log /var/log/nginx/todoapp_s_error.log;
access_log /var/log/nginx/todoapp_s_access.log;
index index.php index.html index.htm;
# Nginx handles the static resources(recommend enabling gzip), LaravelS handles the dynamic resource.
location / {
try_files $uri @laravels;
}
# Response 404 directly when request the PHP file, to avoid exposing public/*.php
#location ~* \.php$ {
# return 404;
#}
# Http and WebSocket are concomitant, Nginx identifies them by "location"
# !!! The location of WebSocket is "/ws"
# Javascript: var ws = new WebSocket("ws://todo-s.test/ws");
# 处理 WebSocket 通信
location =/ws {
# proxy_connect_timeout 60s;
# proxy_send_timeout 60s;
# proxy_read_timeout: Nginx will close the connection if the proxied server does not send data to Nginx in 60 seconds; At the same time, this close behavior is also affected by heartbeat setting of Swoole.
# proxy_read_timeout 60s;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://laravels;
}
location @laravels {
# proxy_connect_timeout 60s;
# proxy_send_timeout 60s;
# proxy_read_timeout 60s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-PORT $remote_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Scheme $scheme;
proxy_set_header Server-Protocol $server_protocol;
proxy_set_header Server-Name $server_name;
proxy_set_header Server-Addr $server_addr;
proxy_set_header Server-Port $server_port;
proxy_pass http://laravels;
}
}
```
配置完成后,即可构建容器并重新启动。
此外,记得在对应 Laravel 项目根目录下`.env`环境配置文件中设置如下配置项:
~~~
LARAVELS_LISTEN_IP=127.0.0.1 // 这里的 IP 需要和 nginx upstream 中配置的监听 IP 保持一致
LARAVELS_DAEMONIZE=true
~~~
### **演示基于Laravel的WebSocket通信**
在 Laravel 项目根目下启动 Swoole 服务器:
~~~
php bin/laravels start
~~~
然后,修改上例`websocket_client.html`中WebSocket Server IP 和端口修改如下:
~~~
// 初始化 WebSocket 客户端套接字并建立与服务器的连接
var socket = new WebSocket("ws://todo-s.test/ws");
~~~
在浏览器中访问这个客户端 HTML 文件,在弹出窗口输入用户名,然后点击「确定」,即可开始建立与 Laravel WebSocket 服务器的通信:
![](https://img.kancloud.cn/50/43/50439f5e195600369517d5eb27d048fc_1402x176.jpg)
同时,在`storage/logs`目录下也可以看到通信连接建立与断开的日志信息:
~~~
[2019-05-22 13:55:02] local.INFO: WebSocket 连接建立
[2019-05-22 13:55:10] local.INFO: WebSocket 连接关闭
[2019-05-22 13:56:25] local.INFO: WebSocket 连接建立
[2019-05-22 13:57:25] local.INFO: WebSocket 连接关闭
[2019-05-22 14:01:18] local.INFO: WebSocket 连接建立
[2019-05-22 14:02:20] local.INFO: WebSocket 连接关闭
~~~