背景

需要在大屏上展示订单趋势、订单国家、店铺数、商品排行、页面访问量等数据

选型

  • 后端:hyperf
  • 前端:react

后端

地图点亮订单国家城市点,使用 WebSocket 消息推送

利用 WebSocket 协议让客户端和服务器端保持有状态的长链接,保存链接上来的客户端 id。订阅发布者发布的消息针对已保存的客户端 id 进行广播消息。

WebSocket 服务

1
composer require hyperf/websocket-server

配日志文件 config/autoload/server.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
return [
    'mode' => SWOOLE_PROCESS,
    'servers' => [
        [
            'name' => 'http',
            'type' => Server::SERVER_HTTP,
            'host' => '0.0.0.0',
            'port' => 9508,
            'sock_type' => SWOOLE_SOCK_TCP,
            'callbacks' => [
                SwooleEvent::ON_REQUEST => [Hyperf\HttpServer\Server::class, 'onRequest'],
            ],
        ],
        [
            'name' => 'ws',
            'type' => Server::SERVER_WEBSOCKET,
            'host' => '0.0.0.0',
            'port' => 19508,
            'sock_type' => SWOOLE_SOCK_TCP,
            'callbacks' => [
                SwooleEvent::ON_HAND_SHAKE => [Hyperf\WebSocketServer\Server::class, 'onHandShake'],
                SwooleEvent::ON_MESSAGE => [Hyperf\WebSocketServer\Server::class, 'onMessage'],
                SwooleEvent::ON_CLOSE => [Hyperf\WebSocketServer\Server::class, 'onClose'],
            ],
            'settings' => [
                // 'open_websocket_ping_frame' => true,
            ]
        ],
    ],
    'settings' => [
        'enable_coroutine' => true,
        'worker_num' => swoole_cpu_num(),
        'pid_file' => BASE_PATH . '/runtime/hyperf.pid',
        'open_tcp_nodelay' => true,
        'max_coroutine' => 100000,
        'open_http2_protocol' => true,
        'max_request' => 100000,
        'socket_buffer_size' => 2 * 1024 * 1024,
        'buffer_output_size' => 2 * 1024 * 1024,
    ],
    'callbacks' => [
        SwooleEvent::ON_WORKER_START => [Hyperf\Framework\Bootstrap\WorkerStartCallback::class, 'onWorkerStart'],
        SwooleEvent::ON_PIPE_MESSAGE => [Hyperf\Framework\Bootstrap\PipeMessageCallback::class, 'onPipeMessage'],
        SwooleEvent::ON_WORKER_EXIT => [Hyperf\Framework\Bootstrap\WorkerExitCallback::class, 'onWorkerExit'],
    ],
];

示例代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<?php
declare(strict_types=1);

namespace App\Controller;

use Hyperf\Contract\OnCloseInterface;
use Hyperf\Contract\OnMessageInterface;
use Hyperf\Contract\OnOpenInterface;
use Hyperf\Utils\ApplicationContext;
use Swoole\Http\Request;
use Swoole\Server;
use Swoole\Websocket\Frame;
use Swoole\WebSocket\Server as WebSocketServer;

class WebSocketController implements OnMessageInterface, OnOpenInterface, OnCloseInterface
{

    protected $key;

    protected $ttl = 7200;

    protected $container;
    protected $redis;

    public function __construct()
    {
        $this->container = ApplicationContext::getContainer();
        $this->redis = $this->container->get(\Hyperf\Redis\Redis::class);
        $this->key = config('rdkey.ws_visited_client');
    }

    /**
     * 发送消息
     * @param \Swoole\Http\Response|WebSocketServer $server
     * @param Frame $frame
     */
    public function onMessage($server, Frame $frame): void
    {
        $fdList = $this->redis->sMembers($this->key);
        //如果当前客户端在客户端集合中,就刷新
        if (in_array($frame->fd, $fdList)) {
            $this->appendFd($frame->fd);
        } else {
            $server->push($frame->fd, 'disconnect');
        }
    }

    /**
     * 客户端失去连接
     * @param \Swoole\Http\Response|Server $server
     * @param int $fd
     * @param int $reactorId
     */
    public function onClose($server, int $fd, int $reactorId): void
    {
        $this->redis->sRem($this->key, $fd);
        // var_dump('closed');
    }

    /**
     * 客户端连接
     * @param \Swoole\Http\Response|WebSocketServer $server
     * @param Request $request
     */
    public function onOpen($server, Request $request): void
    {
        if ($this->appendFd($request->fd)) {
            $server->push($request->fd, 'Opened Succeed');
        } else {
            $server->push($request->fd, 'Opened Failed');
        }
    }

    /**
     * 维护客户端
     * @param $fd
     * @return bool
     */
    private function appendFd($fd)
    {
        return $this->redis->sAdd($this->key, $fd) && $this->redis->expire($this->key, $this->ttl);
    }

}

数据流转通道,订单数据地理信息、访问者地理信息

  • AMQP 各系统流转方便
  • Redis 系统内部流转

前端

  • 每隔 5 秒钟发送一次心跳,避免 websocket 连接因超时而自动断开
  • 错误捕获
  • 连接断开,后端清理 fd
  • 获取经纬度数据后,点亮地图

问题

请求

1
2
3
Request URL: wss://x.x.com/socket/
Request Method: GET
Status Code: 101 Switching Protocols

Reponse Headers

1
2
3
4
5
6
Connection: upgrade
Date: Thu, 05 Nov 2020 10:34:58 GMT
Sec-Websocket-Accept: Xxx2sQC8/ffwX+DoDNQDZ+MP70s=
Sec-Websocket-Version: 13
Server: nginx/1.18.0
Upgrade: websocket

Request Headers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: Upgrade
Host: x.x.com
Origin: https://x.x.com
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: ssQ4Dv586zgMX4pji4SeNA==
Sec-WebSocket-Version: 13
Upgrade: websocket
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36

问题1:websocket通信failed to execute ‘send’问题的解决

浏览器Console报错:

InvalidStateError: Failed to execute ‘send’ on ‘WebSocket’: Still in CONNECTING state.

要明白这个问题产生的原因,就需要了解websocket的几个状态。

通常在实例化一个websocket对象之后,客户端就会与服务器进行连接。但是连接的状态是不确定的,于是用readyState属性来进行标识。

它有四个值,分别对应不同的状态:

  • CONNECTING :值为0,表示正在连接;
  • OPEN :值为1,表示连接成功,可以通信了;
  • CLOSING :值为2,表示连接正在关闭;
  • CLOSED :值为3,表示连接已经关闭,或者打开连接失败。

这样问题的原因就很明显了,之所以数据不能发送出去,是因为websocket还处在“CONNECTING”状态下,连接还没有成功。

解决方案

只要在函数中添加对状态的判断,在状态为OPEN时,执行send方法即可。方法一代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
...

var socket = new WebSocket("ws://127.0.0.1:8000/ws");

//添加状态判断,当为OPEN时,发送消息
if (socket.readyState===1) {
    socket.send(JSON.stringify(message));
}else{
    //do something
}

...

问题2:Error during WebSocket handshake: Unexpected response code: 200 错误解决

拦截器的原因,经过反复查找并未发现拦截器有问题

报错的原因还是是拦截的问题,就是系统认证框架或者过滤器 拦截器等 对此访问连接进行了拦截导致,因此我们分析该问题的时候可以先将系统的所有可能拦截用户访问的组件全部关掉,再尝试一下,应该是可以访问成功的

我这里是 后台应用使用了JWT认证导致的,关闭后,handshake握手成功.

这里 我将websocket 连接添加到 JWT认证范围外,验证通过,完美解决

wss连接的一些坑

WebSocket 介绍

以前,很多网站为了实现实时推送技术,所用的技术都是轮询。轮询是在特定的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端。这种传统的模式带来的缺点很明显,即浏览器需要不断的向服务器发出请求,然而HTTP请求包含较多的请求头信息,而其中真正有效的数据只是很小的一部分,显然这样会浪费很多的带宽等资源。在这种情况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

WebSocket是一种在单个TCP连接上进行全双工通讯的协议,使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

HTTP、HTTPS、WS、WSS

  1. 申领证书
  2. 配置https
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
server {
	#listen 80; #如果需要同时支持http和https
	listen 443 ssl http2;
	listen [::]:443 ssl http2;
	
	ssl_certificate "/root/ssl/1_{域名}_bundle.crt";
	ssl_certificate_key "/root/ssl/2_{域名}.key";
	ssl_session_cache shared:SSL:1m;
	ssl_session_timeout 10m;
	ssl_ciphers HIGH:!aNULL:!MD5;
	ssl_prefer_server_ciphers on;
	
	server_name {域名};
	
	location / {
		proxy_pass http://localhost:{代理端口};
	}
}
  1. 事故现场

Mixed Content: The page at https://{域名}.com/ was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint ws://{ip}:{port}/. This request has been blocked; this endpoint must be available over WSS.

Uncaught DOMException: Failed to construct ‘WebSocket’: An insecure WebSocket connection may not be initiated from a page loaded over HTTPS.

WebSocket connection to wss://{ip}:{port}/ failed: Error in connection establishment: net::ERR_SSL_PROTOCOL_ERROR

WebSocket connection to wss://{域名}/ failed: Error during WebSocket handshake: Unexpected response code: 400

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
server {
	location / {
		proxy_pass http://localhost:{port};
		proxy_http_version 1.1;
		proxy_set_header Upgrade $http_upgrade;
		proxy_set_header Connection "upgrade";
	}
	
	location /socket/ {
		proxy_pass http://localhost:{port};
		proxy_http_version 1.1;
		proxy_set_header Upgrade $http_upgrade;
		proxy_set_header Connection "upgrade";
	}
}

注意

代码路由需要接收对应的 https://domainhttps://domain/socket/

接着,连忙拿域名进行再次连接测试,终于看到了101 Switching Protocols的响应Status Code。就这样,也算是终于解决完在 HTTPS 下以 wss://{域名}/ 的方式连接 WebSocket的一系列问题。

关于Nginx中的WebSocket配置

   自1.3 版本开始,Nginx就支持 WebSocket,并且可以为 WebSocket 应用程序做反向代理和负载均衡。WebSocket 和 HTTP 是两种不同的协议,但是 WebSocket 中的握手和 HTTP 中的握手兼容,它使用 HTTP 中的 Upgrade 协议头将连接从 HTTP 升级到 WebSocket,当客户端发过来一个 Connection: Upgrade请求头时,其实Nginx是不知道的。所以,当 Nginx 代理服务器拦截到一个客户端发来的 Upgrade 请求时,需要我们显式的配置Connection、Upgrade头信息,并使用 101(交换协议)返回响应,在客户端、代理服务器和后端应用服务之间建立隧道来支持 WebSocket。

   当然,还需要注意一点,此时WebSocket 仍然受到 Nginx 缺省为60秒的 proxy_read_timeout 配置影响。这意味着,如果你有一个程序使用了 WebSocket,但又可能超过60秒不发送任何数据的话,那么需要增大超时时间(配置proxy_read_timeout),要么实现一个Ping、Pong的心跳消息以保持客户端和服务端的联系。使用Ping、Pong的解决方法有额外的好处,如:可以发现连接是否被意外关闭等。   

关于最后的这个小问题,主要是在对Nginx配置的时候将location=/的请求都进行了proxy_pass(转发)。由于h5客户端的文件打包成静态文件后,存放在服务器的指定目录下(这里假设在/root/html/static/路径下),这也就导致这种配置的情况下Nginx无法正常代理指定目录下的客户端文件。于是再一次修改配置文件,添加location配置,最终解决所有问题。

1
2
3
location /static/ {
	root /root/html;
}