laravel整合swoole实现倒计时调整页面元素

2018-04-21 10:19:28

场景

在比如说秒杀购物等,一般都会有一个倒计时,等倒计时完成以后,用户就可以点击下单了。

准备

需要网站管理员在后台设置一个秒杀开始时间,然后把这个时间传给前台。前台接收到这个时间以后,写一段js计算出剩余多长时间,然后把倒计时展示出来,倒计时时间每隔1秒刷新一次。使用setInterval就ok

注意

需要注意的一点是,后台必须要有相应的判断,不然的话,别人通过修改前端的元素就轻易绕过这个倒计时了。

正文

当然,前面说的那些基本上就满足了倒计时的功能设计了。至于倒计时完成的那一秒钟需要做的事情是什么。我见过有的系统中设计,只要当前页面一刷新,就发请求给后端,然后后端开始检查,如果确实时间已经到了,就改变此商品为可购买的状态。
但是这样设计有很大的不足,首先,倒计时没有完成的时候,如果有人刷新页面,后端同样需要做判断,当然这属于毫无意义的判断,纯粹增加服务器的负担。而如果倒计时完成以后,如果没有人刷新当前的页面,那么此商品的状态一直无法更改为可购买的状态。

通常的做法是,后端在倒计时完成的那一秒钟,自动处理一下此商品的状态,把商品标记为可购买的状态。

关于这个功能的实现,我见过很多通过cron脚本去实现的。具体实现,需要根据不同的情况来。如果一个网站,每天在固定的时间提供秒杀服务,那么只需要把cron脚本调整到这个时间去执行去好了,这样也不失为一种比较好的解决方法,成本比较低。

但是如果一个网站每天随机发布秒杀倒计时活动,那么情况就不那么容易处理了。当然,我也见过把cron设计为每分钟执行一次,因为秒杀活动一般都是整点整分的,这样无疑又造成了性能的浪费了。

引入swoole

比较好的方案就是引入swoole,并使用其中的定时功能,官方例子

function onWorkerStart(swoole_server $serv, $worker_id)
{
    if (!$serv->taskworker) {
        $serv->tick(1000, function ($id) {
            var_dump($id);
        });
    }
    else
    {
        $serv->addtimer(1000);
    }
}

这样看来,它是可以支持到ms级别的定时任务,于是我们使用他的时候,只需要把商品的标识,以及时间传过去,计算出倒计时时间,然后在回调函数中改变商品的状态就可以了,挺方便的。

当然,写了那么多的字,还是离题比较远。我们主要讨论的是,在服务端改变商品的状态时,客户端应该怎么处理比较合适。

场景

当倒计时结束以后,前台应该怎么处理呢?
1.通过js脚本直接刷新当前页面
2.通过提示信息,让用户手动刷新当前页面。
3.自动改变当前的秒杀状态,让其变为可购买的状态。

前两种就不讨论了,我们主要是讨论一下第三种。这一种无疑对用户体验是最好的

于是我们实现它,必须要架立一个websocket服务器。好在swoole几行代码就帮我们建立了一个性能非常稳定的websocket服务器,但需要我们整合到laravel中,首先我们整合service端启动脚本

<?php

namespace App\Console\Commands\Swoole;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class WebSwoole extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'webSwoole {action}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = "Let's use webSwoole !";

    private $server;

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $arg = $this->argument('action');
        switch ($arg) {
            case 'start':
                $this->start();
                break;
            case 'stop':
                $this->stop();
                break;
            case 'restart':
                break;
        }
    }

    private function start()
    {


        $this->server = new \swoole_websocket_server("0.0.0.0", 11301);

        $this->server->set(array(
            'worker_num' => 4,
            'daemonize' => true,
            'max_request' => 10000,
            'dispatch_mode' => 2,
            'log_file' => storage_path('logs/websocket.log'),
        ));

        $this->server->on('open', function (\swoole_websocket_server $server, $request) {
            echo "server: handshake success with fd{$request->fd}\n";
        });
        $this->server->on('message', function (\swoole_websocket_server $server, $frame) {
            echo "receive from {$frame->fd}:{$frame->data},opcode:{$frame->opcode},fin:{$frame->finish}\n";

            $timeon = 1*1000;
            if($timeon>0){
                $server->after($timeon, function() use ($frame) {
                    foreach ($this->server->connections as $fd) {
                        $this->server->push($fd, $frame->data);
                    }
                });
            }


        });
        $this->server->on('close', function ($ser, $fd) {
            echo "client {$fd} closed\n";
        });

        $this->server->start();

    }

    private function stop()
    {
        exec('/usr/bin/killall php');
    }

}

此时我们在控制台执行,php artisan webSwoole start,webSocket服务器就成功启动起来了。

上面的代码中message中,是收到客户端信息以后的处理逻辑,当然上面只是演示代码,大致意思是,当收到消息的时候,然后把此消息在1秒后广播给所有的客户端。
当然,出于安全。客户端发的数据是要和服务端达成一定的规则的,正确的做法是,我们只接收指定的客户端,并且全程实现加密传输。这些不就表了。

也许有人会问,那如果我还有一个websocket服务,这一个广播给所有的用户,那会不会造成数据污染,如何分别对不同种类的用户进行广播呢?

当然是可以的,这就需要引入频道的概念了。正常的做法是,我们把特定频道的$fd保存在一组数据里面,然后通过修改这一行代码

foreach ($this->server->connections as $fd) {
    //somethings
}

具体就不细说了。

我们的目标是,在网站管理员发布秒杀商品,并设定开始时间时。我们需要通知websocket服务器,把时间和商品信息带过去,然后websocket服务器算出定时ms数,然后定时发给所有的客户端通知,客户端拿到通知时,修改当前页面的商品状态,使其变成可购买的状态。

于是我们需要的一个php的websocket client端

相应代码
webSocket.php

<?php
/**
 * Created by PhpStorm.
 * User: nosay
 * Date: 4/20/18
 * Time: 10:31 AM
 */

namespace App\Extension\php\Swoole;


class webSocket
{

    private $client;
    private $state;
    private $host;
    private $port;
    private $handler;
    private $buffer;
    private $openCb;
    private $messageCb;
    private $closeCb;
    const HANDSHAKING = 1;
    const HANDSHAKED = 2;
    const WEBSOCKET_OPCODE_CONTINUATION_FRAME = 0x0;
    const WEBSOCKET_OPCODE_TEXT_FRAME = 0x1;
    const WEBSOCKET_OPCODE_BINARY_FRAME = 0x2;
    const WEBSOCKET_OPCODE_CONNECTION_CLOSE = 0x8;
    const WEBSOCKET_OPCODE_PING = 0x9;
    const WEBSOCKET_OPCODE_PONG = 0xa;
    const TOKEN_LENGHT = 16;
    public function __construct()
    {
        $this->client = new \swoole_client(SWOOLE_SOCK_TCP, SWOOLE_SOCK_ASYNC);
        $this->client->on("connect", [$this, "onConnect"]);
        $this->client->on("receive", [$this, "onReceive"]);
        $this->client->on("close", [$this, "onClose"]);
        $this->client->on("error", [$this, "onError"]);
        $this->handler = new PacketHandler();
        $this->buffer = "";
    }
    public function connect($host, $port)
    {
        $this->host = $host;
        $this->port = $port;
        $this->client->connect($host, $port);
    }
    public function sendHandShake()
    {
        $this->state = static::HANDSHAKING;
        $request = $this->handler->buildHandShakeRequest($this->host, $this->port);
        $this->client->send($request);
    }
    public function onConnect($cli)
    {
        $this->sendHandShake();
    }
    public function onReceive($cli, $data)
    {
        if ($this->state == static::HANDSHAKING) {
            $this->buffer .= $data;
            $pos = strpos($this->buffer, "\r\n\r\n", true);
            if ($pos != false) {
                $header = substr($this->buffer, 0, $pos + 4);
                $this->buffer = substr($this->buffer, $pos + 4);
                if (true == $this->handler->verifyUpgrade($header)) {
                    $this->state = static::HANDSHAKED;
                    if (isset($this->openCb))
                        call_user_func($this->openCb, $this);
                } else {
                    echo "handshake failed\n";
                }
            }
        } else if ($this->state == static::HANDSHAKED) {
            $this->buffer .= $data;
        }
        if ($this->state == static::HANDSHAKED) {
            try {
                $frame = $this->handler->processDataFrame($this->buffer);
            } catch (\Exception $e) {
                $cli->close();
                return;
            }
            if ($frame != null) {
                if (isset($this->messageCb))
                    call_user_func($this->messageCb, $this, $frame);
            }
        }
    }
    public function onClose($cli)
    {
        if (isset($this->closeCb))
            call_user_func($this->closeCb, $this);
    }
    public function onError($cli)
    {
        echo "error occurred\n";
    }
    public function on($event, $callback)
    {
        if (strcasecmp($event, "open") === 0) {
            $this->openCb = $callback;
        } else if (strcasecmp($event, "message") === 0) {
            $this->messageCb = $callback;
        } else if (strcasecmp($event, "close") === 0) {
            $this->closeCb = $callback;
        } else {
            echo "$event is not supported\n";
        }
    }
    public function send($data, $type = 'text')
    {
        switch($type)
        {
            case 'text':
                $_type = self::WEBSOCKET_OPCODE_TEXT_FRAME;
                break;
            case 'binary':
            case 'bin':
                $_type = self::WEBSOCKET_OPCODE_BINARY_FRAME;
                break;
            case 'ping':
                $_type = self::WEBSOCKET_OPCODE_PING;
                break;
            case 'close':
                $_type = self::WEBSOCKET_OPCODE_CONNECTION_CLOSE;
                break;
            case 'ping':
                $_type = self::WEBSOCKET_OPCODE_PING;
                break;
            case 'pong':
                $_type = self::WEBSOCKET_OPCODE_PONG;
                break;
            default:
                echo "$type is not supported\n";
                return;
        }
        $data = \swoole_websocket_server::pack($data, $_type);
        $this->client->send($data);
    }
    public function getTcpClient()
    {
        return $this->client;
    }
}

PacketHandler.php

<?php
/**
 * Created by PhpStorm.
 * User: nosay
 * Date: 4/20/18
 * Time: 10:33 AM
 */

namespace App\Extension\php\Swoole;


class PacketHandler {
    const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    const TOKEN_LENGHT = 16;
    const maxPacketSize = 2000000;
    private $key = "";
    private static function generateToken($length)
    {
        $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"§$%&/()=[]{}';
        $useChars = array();
        // select some random chars:
        for ($i = 0; $i < $length; $i++) {
            $useChars[] = $characters[mt_rand(0, strlen($characters) - 1)];
        }
        // Add numbers
        array_push($useChars, rand(0, 9), rand(0, 9), rand(0, 9));
        shuffle($useChars);
        $randomString = trim(implode('', $useChars));
        $randomString = substr($randomString, 0, self::TOKEN_LENGHT);
        return base64_encode($randomString);
    }
    public function buildHandShakeRequest($host, $port)
    {
        $this->key = static::generateToken(self::TOKEN_LENGHT);
        return "GET / HTTP/1.1" . "\r\n" .
            "Origin: null" . "\r\n" .
            "Host: {$host}:{$port}" . "\r\n" .
            "Sec-WebSocket-Key: {$this->key}" . "\r\n" .
            "User-Agent: SwooleWebsocketClient"."/0.1.4" . "\r\n" .
            "Upgrade: Websocket" . "\r\n" .
            "Connection: Upgrade" . "\r\n" .
            "Sec-WebSocket-Protocol: wamp" . "\r\n" .
            "Sec-WebSocket-Version: 13" . "\r\n" . "\r\n";
    }
    public function verifyUpgrade($packet)
    {
        $headers = explode("\r\n", $packet);
        unset($headers[0]);
        $headerInfo = [];
        foreach ($headers as $header) {
            $arr = explode(":", $header);
            if (count($arr) == 2) {
                list($field, $value) = $arr;
                $headerInfo[trim($field)] = trim($value);
            }
        }
        return (isset($headerInfo['Sec-WebSocket-Accept']) && $headerInfo['Sec-WebSocket-Accept'] == base64_encode(pack('H*', sha1($this->key.self::GUID))));
    }
    public function processDataFrame(&$packet)
    {
        if (strlen($packet) < 2)
            return null;
        $header = substr($packet, 0, 2);
        $index = 0;
        //fin:1 rsv1:1 rsv2:1 rsv3:1 opcode:4
        $handle = ord($packet[$index]);
        $finish = ($handle >> 7) & 0x1;
        $rsv1 = ($handle >> 6) & 0x1;
        $rsv2 = ($handle >> 5) & 0x1;
        $rsv3 = ($handle >> 4) & 0x1;
        $opcode = $handle & 0xf;
        $index++;
        //mask:1 length:7
        $handle = ord($packet[$index]);
        $mask = ($handle >> 7) & 0x1;
        //0-125
        $length = $handle & 0x7f;
        $index++;
        //126 short
        if ($length == 0x7e)
        {
            if (strlen($packet) < $index + 2)
                return null;
            //2 byte
            $handle = unpack('nl', substr($packet, $index, 2));
            $index += 2;
            $length = $handle['l'];
        }
        //127 int64
        elseif ($length > 0x7e)
        {
            if (strlen($packet) < $index + 8)
                return null;
            //8 byte
            $handle = unpack('Nh/Nl', substr($packet, $index, 8));
            $index += 8;
            $length = $handle['l'];
            if ($length > static::maxPacketSize)
            {
                throw new \Exception("frame length is too big.\n");
            }
        }
        //mask-key: int32
        if ($mask)
        {
            if (strlen($packet) < $index + 4)
                return null;
            $mask = array_map('ord', str_split(substr($packet, $index, 4)));
            $index += 4;
        }
        if (strlen($packet) < $index + $length)
            return null;
        $data = substr($packet, $index, $length);
        $index += $length;
        $packet = substr($packet, $index);
        $frame = new WebSocketFrame();
        $frame->finish = $finish;
        $frame->opcode = $opcode;
        $frame->data = $data;
        return $frame;
    }
}

WebSocketFrame.php

<?php
/**
 * Created by PhpStorm.
 * User: nosay
 * Date: 4/20/18
 * Time: 10:35 AM
 */

namespace App\Extension\php\Swoole;


class WebSocketFrame {
    public $finish;
    public $opcode;
    public $data;
}

我们写一个脚本进行测试
php客户端

<?php

namespace App\Console\Commands\Test;

use App\Extension\php\Swoole\webSocket;
use Illuminate\Console\Command;


class TestCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'Test:Command';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command Test';


    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();

    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {

        $client = new webSocket();
        $client->on("open",function ($client) {
            $msg = [
                "message" => "我找到你了",
            ];
            $client->send(json_encode($msg));
            exit() ;
        });

        $client->connect("xxx.xxxx.xxxx.xxxx", 11301);

    }
}

html客户端

    <!DOCTYPE html>  
    <html lang="en">  
      
    <head>  
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />

        <style>  
            *{  
                margin:0px;  
                padding:0px;  
            }  
        </style>  
    </head>  
      
    <body>  
        <div style="margin-left:400px">  
            <div style="border:1px solid;width: 600px;height: 500px;">  
                <div id="msgArea" style="width:100%;height: 100%;text-align:start;resize: none;font-family: 微软雅黑;font-size: 20px;overflow-y: scroll"></div>  
            </div>  
            <div style="border:1px solid;width: 600px;height: 200px;">  
                <div style="width:100%;height: 100%;">  
                    <textarea id="userMsg" style="width:100%;height: 100%;text-align:start;resize: none;font-family: 微软雅黑;font-size: 20px;"></textarea>  
                </div>  
            </div>  
            <div style="border:1px solid;width: 600px;height: 25px;">  
                <button style="float: right;" onclick="sendMsg()">发送</button>  
            </div>  
        </div>  
    </body>  
      
    </html>  
    <script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script>  
    <script>  
        var ws;  
        $(function(){  
            link();  
        })  
      
        function link () {  
            ws = new WebSocket("ws://xxx.xxx.xxx.xxx:11301");
            ws.onopen = function(event){  
                console.log(event);  
                alert('连接了');  
            };  
            ws.onmessage = function (event) {
console.log(event);            
                var msg = "<p>"+event.data+"</p>";  
                $("#msgArea").append(msg);  
            }  
            ws.onclose = function(event){alert("已经与服务器断开连接\r\n当前连接状态:"+this.readyState);};  
      
            ws.onerror = function(event){alert("WebSocket异常!");};  
        }  
      
        function sendMsg(){  
            var msg = $("#userMsg").val();  
            ws.send(msg);  
        }  
    </script>  

此时执行php脚本来给浏览器发送消息,测试是否成功

php artisan Test:Command 

浏览器中显示如图所示

至此我们就完成了一个简单的模型,就此记录一下。