php中就不能不知道swoole这个扩展了,有了这个扩展很多不可能就变成了可能。
借助于swoole提供的websocket机制,实现一个websocket服务器其实非常简单,我们只需要关注如何正确的管理用户链接以及状态。
实现要点:
- 使用swoole_table 在进程间共享数据,用来存储房间号中的fd列表。
- 处理订阅以及取消订阅的时候要加锁。(涉及到fd列表的反序列化)
我们一起来看看完整的实现
<?php
/**
* websocket 客户端
*/
date_default_timezone_set('Asia/Shanghai');
class Server
{
private $_server;
private $_lock;
private $_table;
private $_config = [];
public function __construct($config)
{
$this->_config = $config;
$this->_lock = new \Swoole\Lock(SWOOLE_MUTEX);
$this->_server = new \Swoole\Websocket\Server($this->_config['ws']['host'], $this->_config['ws']['port']);
$this->_table = new \Swoole\Table($config['ws']['max_channel']);
$this->_table->column('fds', \Swoole\Table::TYPE_STRING, $config['ws']['max_fds'] * 2);// fds 以逗号分隔
$this->_table->create();
}
public function run()
{
$this->_server->on('start', [$this, 'onStart']);
$this->_server->on('open', [$this, 'onOpen']);
$this->_server->on('message', [$this, 'onMessage']);
$this->_server->on('request', [$this, 'onRequest']);
$this->_server->on('close', [$this, 'onClose']);
$this->_server->set($this->_config['websocket_server']);
$this->_server->start();
}
/**
* 打开socket链接回调
*/
public function onOpen($ws, $frame)
{
// 不是websocket链接 有可能是常规的http
if (!$this->is_webscoket($frame->fd)) {
return;
}
$this->broadcast('internal.open', json_encode(['fd' => $frame->fd]));
$this->log('info', "user open the connection", "fd=". $frame->fd);
}
/**
* 程序启动回调
*/
public function onStart($server)
{
$this->log('info', "websocket start success", "address=ws://". $this->_config['ws']['host'] . ":" . $this->_config['ws']['port']);
}
/**
* 收到ws消息的回调 {"event": "", "channel": "", "cid":0}
*/
public function onMessage($server, $frame)
{
$req = json_decode($frame->data, true);
if (empty($req)) {
$this->push($frame->fd, $this->pack($req, [], -1, 'empty request'));
return;
}
if (!isset($req['event'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'event is required'));
return;
}
// 长连接 心跳
if ($req['event'] == 'heartbeat') {
$this->push($frame->fd, $this->pack($req));
return;
}
// 长连接 状态查询
if ($req['event'] == 'status') {
$this->push($frame->fd, $this->handle_status([]));
return;
}
// 长连接订阅
if ($req['event'] == 'sub') {
if (!isset($req['channel'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'channel is required'));
return;
}
$this->sub($req['channel'], $frame->fd);
$this->push($frame->fd, $this->pack($req));
return;
}
// 长连接取消订阅
if ($req['event'] == 'unsub') {
if (!isset($req['channel'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'channel is required'));
return;
}
$this->unsub($req['channel'], $frame->fd);
$this->push($frame->fd, $this->pack($req));
return;
}
// 长连接广播
if ($req['event'] == 'broadcast') {
if (!isset($req['channel'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'channel is required'));
return;
}
if (!isset($req['data'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'data is required'));
return;
}
$this->push($frame->fd, $this->handle_broadcast(['channel' => $req['channel'], 'data' => $req['data']]));
return;
}
// 长连接推送
if ($req['event'] == 'push') {
if (!isset($req['channel'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'channel is required'));
return;
}
if (!isset($req['data'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'data is required'));
return;
}
if (!isset($req['fd'])) {
$this->push($frame->fd, $this->pack($req, [], -1, 'fd is required'));
return;
}
$this->push($frame->fd, $this->handle_push(['channel' => $req['channel'], 'data' => $req['data'],'fd' => $req['fd']]));
return;
}
$this->push($frame->fd, $this->pack($req, [], -1, 'invalid event name'));
}
/**
* http 链接回调 可以通过curl来rpc
*/
public function onRequest($request, $response)
{
$task = json_decode($request->rawContent(), true);
if (empty($task)) {
$response->end($this->pack([], [], -1, 'empty request'));
return;
}
if (!isset($task['method'])) {
$response->end($this->pack([], [], -1, 'method required'));
return;
}
if (!isset($task['params'])) {
$response->end($this->pack([], [], -1, 'params required'));
return;
}
// 广播消息
if ($task['method'] == 'broadcast') {
$ret = $this->handle_broadcast($task['params']);
$response->end($ret);
// 单推
} elseif ($task['method'] == 'push') {
$ret = $this->handle_push($task['params']);
$response->end($ret);
// 状态查询
} elseif ($task['method'] == 'status') {
$ret = $this->handle_status($task['params']);
$response->end($ret);
// 未知请求
} else {
$response->end($this->pack([], [], -1, 'unknow method'));
}
}
/**
* 处理多推
*/
private function handle_broadcast($task)
{
if (!isset($task['channel'])) {
return $this->pack([], [], -1, 'params channel required');
}
if (!isset($task['data'])) {
return $this->pack([], [], -1, 'params data required');
}
$channel = $task['channel'];
$data = $task['data'];
$this->broadcast($channel, $data);
$this->log("info", "handle_broadcast", "task=" . json_encode($task));
return $this->pack([]);
}
/**
* 处理单推
*/
private function handle_push($task)
{
if (!isset($task['channel'])) {
return $this->pack([], [], -1, 'params channel required');
}
if (!isset($task['fd'])) {
return $this->pack([], [], -1, 'params fd required');
}
if (!isset($task['data'])) {
return $this->pack([], [], -1, 'params data required');
}
$channel = $task['channel'];
if (!$this->_table->exist($channel)) {
return;
}
$data = $task['data'];
$fd = $task['fd'];
$raw_msg = $this->pack(['channel' => $channel, 'event' => 'push'], $data);
$this->push($fd, $raw_msg);
$this->log("info", "handle_push", "task=" . json_encode($task));
return $this->pack([]);
}
/**
* 处理状态查询
*/
private function handle_status($task)
{
$status = [];
foreach ($this->_table as $channel => $fds) {
$status[$channel] = $fds['fds'];
}
$this->log("info", "handle_status", "task=" . json_encode($task));
return $this->pack([], $status);
}
/**
* 关闭ws链接回调
*/
public function onClose($server, $fd)
{
// 不是websocket链接
if (!$this->is_webscoket($fd)) {
return;
}
foreach ($this->_table as $channel => $data) {
$fds = explode(',', $data['fds']);
if (array_search($fd, $fds) === false) {
continue;
}
$this->unsub($channel, $fd);
}
$this->broadcast('internal.close', json_encode(['fd' => $fd]));
$this->log("info", "user close the connection", "fd=$fd");
}
/**
* 判断当前连接是否是websocket
*/
private function is_webscoket($fd)
{
$info = $this->_server->connection_info($fd);
if (empty($info) || !isset($info['websocket_status'])) {
return false;
}
return $info['websocket_status'] != 0;
}
/**
* 返回ws响应包
*/
private function pack($req, $data = [], $code = 0, $message = '')
{
$res = [];
$res['cid'] = 0;
if (isset($req['cid'])) {
$res['cid'] = $req['cid'];
}
$res['code'] = $code;
if (isset($req['event'])) {
$res['event'] = $req['event'];
}
if (isset($req['channel'])) {
$res['channel'] = $req['channel'];
}
$res['message'] = $message;
$res['data'] = $data;
return json_encode($res);
}
/**
* 指定fd推送消息
*/
private function push($fd, $msg)
{
if ($this->_server->exist($fd)) {
$this->_server->push($fd, $msg);
} else {
$this->log('error', "push failed fd is not exists", "fd=$fd");
}
}
/**
* 按照频道广播
*/
private function broadcast($channel, $msg)
{
if (!$this->_table->exist($channel)) {
return;
}
$fds = explode(",", $this->_table->get($channel, 'fds'));
if (empty($fds)) {
return;
}
$raw = $this->pack(['channel' => $channel, 'event' => 'push'], $msg);
foreach ($fds as $fd) {
$this->push($fd, $raw);
}
}
/**
* 订阅频道回调
*/
private function sub($channel, $fd)
{
try {
$this->_lock->lock();// 以下操作有竞态 需加锁
$fds = [];
if ($this->_table->exist($channel)) {
$fds = explode(',', trim($this->_table->get($channel, 'fds')));
}
if (array_search($fd, $fds) != false) {
return;
}
$fds[] = $fd;
$this->_table->set($channel, ['fds' => trim(implode(',', $fds))]);
$this->broadcast('internal.sub', json_encode(['channel' => $channel, 'fd' => $fd]));
$this->log(__FUNCTION__, "info", "fd $fd sub channel $channel");
} finally {
$this->_lock->unlock(); // 确保解锁
}
}
/**
* 取消订阅频道回调
*/
private function unsub($channel, $fd)
{
try {
$this->_lock->lock(); // 以下操作有竞态 需加锁
$fds = [];
if ($this->_table->exist($channel)) {
$fds = explode(',', trim($this->_table->get($channel, 'fds')));
}
$index = array_search($fd, $fds);
if ($index === false) {
return;
}
array_splice($fds, $index, 1);
if (empty($fds)) {
$this->_table->del($channel);
} else {
$this->_table->set($channel, ['fds' => trim(implode(',', $fds))]);
}
$this->broadcast('internal.unsub', json_encode(['channel' => $channel, 'fd' => $fd]));
$this->log(__FUNCTION__, "info", "fd $fd unsub channel $channel");
} finally {
$this->_lock->unlock(); // 确保解锁
}
}
/**
* 记录日志
*/
private function log()
{
$args = func_get_args();
$msg = implode(",", $args);
echo date('[Y-m-d H:i:s]') . "," . $msg . "\n";
}
};