- _nosay
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
浏览器中显示如图所示
至此我们就完成了一个简单的模型,就此记录一下。