初始化

This commit is contained in:
2026-01-06 13:37:07 +08:00
parent c3435595fe
commit 00d7a381aa
70 changed files with 3913 additions and 1 deletions

23
src/App.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace think\worker;
class App extends \think\App
{
protected $inConsole = true;
public function setInConsole($inConsole = true)
{
$this->inConsole = $inConsole;
}
public function runningInConsole(): bool
{
return $this->inConsole;
}
public function clearInstances()
{
$this->instances = [];
}
}

22
src/Conduit.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker;
/**
* @mixin \think\worker\conduit\Driver
*/
class Conduit extends \think\Manager
{
protected $namespace = "\\think\\worker\\conduit\\driver\\";
protected function resolveConfig(string $name)
{
return $this->app->config->get("worker.conduit.{$name}", []);
}
public function getDefaultDriver()
{
return $this->app->config->get('worker.conduit.type', 'socket');
}
}

17
src/Http.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace think\worker;
class Http extends \think\Http
{
protected function loadMiddleware(): void
{
}
protected function loadRoutes(): void
{
}
}

41
src/Ipc.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace think\worker;
class Ipc
{
protected $workerId;
public function __construct(protected Manager $manager, protected Conduit $conduit)
{
}
public function listenMessage()
{
$this->subscribe();
return $this->workerId;
}
public function sendMessage($workerId, $message)
{
if ($workerId === $this->workerId) {
$this->manager->triggerEvent('message', $message);
} else {
$this->publish($workerId, $message);
}
}
public function subscribe()
{
$this->workerId = $this->conduit->inc('ipc:worker');
$this->conduit->subscribe("ipc:message:{$this->workerId}", function ($message) {
$this->manager->triggerEvent('message', unserialize($message));
});
}
public function publish($workerId, $message)
{
$this->conduit->publish("ipc:message:{$workerId}", serialize($message));
}
}

27
src/Manager.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace think\worker;
use think\worker\concerns\InteractsWithConduit;
use think\worker\concerns\InteractsWithHttp;
use think\worker\concerns\InteractsWithQueue;
use think\worker\concerns\InteractsWithServer;
use think\worker\concerns\WithApplication;
use think\worker\concerns\WithContainer;
class Manager
{
use InteractsWithServer,
InteractsWithHttp,
InteractsWithQueue,
InteractsWithConduit,
WithApplication,
WithContainer;
protected function initialize(): void
{
$this->prepareHttp();
$this->prepareQueue();
$this->prepareConduit();
}
}

210
src/Sandbox.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
namespace think\worker;
use Closure;
use InvalidArgumentException;
use ReflectionObject;
use RuntimeException;
use think\Config;
use think\Container;
use think\Event;
use think\exception\Handle;
use think\worker\App;
use think\worker\concerns\ModifyProperty;
use think\worker\contract\ResetterInterface;
use think\worker\resetters\ClearInstances;
use think\worker\resetters\ResetConfig;
use think\worker\resetters\ResetEvent;
use think\worker\resetters\ResetModel;
use think\worker\resetters\ResetPaginator;
use think\worker\resetters\ResetService;
use Throwable;
use WeakMap;
class Sandbox
{
use ModifyProperty;
/** @var App|null */
protected $snapshot;
/** @var WeakMap */
protected $snapshots;
/** @var App */
protected $app;
/** @var Config */
protected $config;
/** @var Event */
protected $event;
/** @var ResetterInterface[] */
protected $resetters = [];
protected $services = [];
public function __construct(App $app)
{
$this->app = $app;
$this->snapshots = new WeakMap();
$this->initialize();
}
protected function initialize()
{
Container::setInstance(function () {
return $this->getSnapshot();
});
$this->setInitialConfig();
$this->setInitialServices();
$this->setInitialEvent();
$this->setInitialResetters();
}
public function run(Closure $callable, ?object $key = null)
{
$this->snapshot = $this->createApp($key);
try {
$this->snapshot->invoke($callable, [$this]);
} catch (Throwable $e) {
$this->snapshot->make(Handle::class)->report($e);
} finally {
if (empty($key)) {
$this->snapshot->clearInstances();
}
$this->snapshot = null;
$this->setInstance($this->app);
}
}
protected function createApp(?object $key = null)
{
if (!empty($key)) {
if (isset($this->snapshots[$key])) {
return $this->snapshots[$key]->app;
}
}
$app = clone $this->app;
$this->setInstance($app);
$this->resetApp($app);
if (!empty($key)) {
$this->snapshots[$key] = new class($app) {
public function __construct(public App $app)
{
}
public function __destruct()
{
$this->app->clearInstances();
}
};
}
return $app;
}
protected function resetApp(App $app)
{
foreach ($this->resetters as $resetter) {
$resetter->handle($app, $this);
}
}
protected function setInstance(App $app)
{
$app->instance('app', $app);
$app->instance(Container::class, $app);
$reflectObject = new ReflectionObject($app);
$reflectProperty = $reflectObject->getProperty('services');
$services = $reflectProperty->getValue($app);
foreach ($services as $service) {
$this->modifyProperty($service, $app);
}
}
/**
* Set initial config.
*/
protected function setInitialConfig()
{
$this->config = clone $this->app->config;
}
protected function setInitialEvent()
{
$this->event = clone $this->app->event;
}
protected function setInitialServices()
{
$services = $this->config->get('worker.services', []);
foreach ($services as $service) {
if (class_exists($service) && !in_array($service, $this->services)) {
$serviceObj = new $service($this->app);
$this->services[$service] = $serviceObj;
}
}
}
/**
* Initialize resetters.
*/
protected function setInitialResetters()
{
$resetters = [
ClearInstances::class,
ResetConfig::class,
ResetEvent::class,
ResetService::class,
ResetModel::class,
ResetPaginator::class,
];
$resetters = array_merge($resetters, $this->config->get('worker.resetters', []));
foreach ($resetters as $resetter) {
$resetterClass = $this->app->make($resetter);
if (!$resetterClass instanceof ResetterInterface) {
throw new RuntimeException("{$resetter} must implement " . ResetterInterface::class);
}
$this->resetters[$resetter] = $resetterClass;
}
}
public function getSnapshot()
{
$snapshot = $this->snapshot;
if ($snapshot instanceof App) {
return $snapshot;
}
throw new InvalidArgumentException('The app object has not been initialized');
}
/**
* Get config snapshot.
*/
public function getConfig()
{
return $this->config;
}
public function getEvent()
{
return $this->event;
}
public function getServices()
{
return $this->services;
}
}

23
src/Service.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
namespace think\worker;
use think\worker\command\Server;
class Service extends \think\Service
{
public function boot()
{
$this->commands([
Server::class,
]);
}
}

33
src/Watcher.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace think\worker;
use think\worker\watcher\Driver;
/**
* @mixin Driver
*/
class Watcher extends \think\Manager
{
protected $namespace = '\\think\\worker\\watcher\\';
protected function getConfig(string $name, $default = null)
{
return $this->app->config->get('worker.hot_update.' . $name, $default);
}
protected function resolveParams($name): array
{
return [
array_filter($this->getConfig('include', []), function ($dir) {
return is_dir($dir);
}),
$this->getConfig('exclude', []),
$this->getConfig('name', []),
];
}
public function getDefaultDriver()
{
return $this->getConfig('type', 'scan');
}
}

121
src/Websocket.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
namespace think\worker;
use RuntimeException;
use think\Event;
use think\worker\websocket\Pusher;
use think\worker\websocket\Room;
use Workerman\Connection\TcpConnection;
/**
* Class Websocket
*/
class Websocket
{
/**
* @var \think\App
*/
protected $app;
/**
* @var Room
*/
protected $room;
/**
* @var string
*/
protected $sender;
/** @var Event */
protected $event;
/** @var TcpConnection|null */
protected $connection;
protected $connected = true;
/**
* Websocket constructor.
*
* @param \think\App $app
* @param Room $room
* @param Event $event
*/
public function __construct(\think\App $app, Room $room, Event $event, ?TcpConnection $connection = null)
{
$this->app = $app;
$this->room = $room;
$this->event = $event;
$this->connection = $connection;
}
/**
* @return Pusher
*/
protected function makePusher()
{
return $this->app->invokeClass(Pusher::class);
}
public function to(...$values)
{
return $this->makePusher()->to(...$values);
}
public function push($data)
{
$this->makePusher()->to($this->getSender())->push($data);
}
public function emit(string $event, ...$data)
{
$this->makePusher()->to($this->getSender())->emit($event, ...$data);
}
public function join(...$rooms): self
{
$this->room->add($this->getSender(), ...$rooms);
return $this;
}
public function leave(...$rooms): self
{
$this->room->delete($this->getSender(), ...$rooms);
return $this;
}
public function setConnected($connected)
{
$this->connected = $connected;
}
public function isEstablished()
{
return $this->connected;
}
public function close()
{
if ($this->connection) {
$this->connection->close();
}
}
public function setSender(string $fd)
{
$this->sender = $fd;
return $this;
}
public function getSender()
{
if (empty($this->sender)) {
throw new RuntimeException('Cannot use websocket as current client before handshake!');
}
return $this->sender;
}
}

19
src/Worker.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace think\worker;
class Worker extends \Workerman\Worker
{
protected static function init(): void
{
static::$pidFile = runtime_path() . 'worker.pid';
static::$statusFile = runtime_path() . 'worker.status';
static::$logFile = runtime_path() . 'worker.log';
parent::init();
}
protected static function parseCommand(): void
{
}
}

35
src/command/Server.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
namespace think\worker\command;
use think\console\Command;
use think\worker\Manager;
/**
* Worker Server 命令行类
*/
class Server extends Command
{
protected $config = [];
public function configure()
{
$this->setName('worker')
->setDescription('Workerman Server for ThinkPHP');
}
public function handle(Manager $manager)
{
$manager->start();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace think\worker\concerns;
use think\worker\Conduit;
trait InteractsWithConduit
{
/** @var Conduit */
protected $conduit;
protected function prepareConduit()
{
$this->conduit = $this->container->make(Conduit::class);
$this->conduit->prepare();
$this->onEvent('workerStart', function () {
$this->app->instance(Conduit::class, $this->conduit);
});
}
}

View File

@@ -0,0 +1,321 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\Cookie;
use think\Event;
use think\exception\Handle;
use think\helper\Str;
use think\Http;
use think\response\View;
use think\worker\App as WorkerApp;
use think\worker\Http as WorkerHttp;
use think\worker\response\File as FileResponse;
use think\worker\response\Iterator as IteratorResponse;
use think\worker\websocket\Frame;
use think\worker\Worker;
use Throwable;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http\Chunk;
use Workerman\Protocols\Http\Request as WorkerRequest;
use Workerman\Protocols\Http\Response;
use function substr;
/**
* Trait InteractsWithHttp
* @property App $app
* @property App $container
*/
trait InteractsWithHttp
{
use ModifyProperty, InteractsWithWebsocket;
protected $wsEnable = false;
protected function prepareHttp()
{
if ($this->getConfig('http.enable', true)) {
$this->wsEnable = $this->getConfig('websocket.enable', false);
if ($this->wsEnable) {
$this->prepareWebsocket();
}
$workerNum = $this->getConfig('http.worker_num', 4);
$this->addWorker([$this, 'createHttpServer'], 'http server', $workerNum);
}
}
public function createHttpServer()
{
$this->preloadHttp();
$host = $this->getConfig('http.host');
$port = $this->getConfig('http.port');
$options = $this->getConfig('http.options', []);
$server = new Worker("\\think\\worker\\protocols\\FlexHttp://{$host}:{$port}", $options);
$server->reusePort = true;
$server->onMessage = function (TcpConnection $connection, $data) {
if ($data instanceof WorkerRequest) {
if ($this->wsEnable && $this->isWebsocketRequest($data)) {
$this->onHandShake($connection, $data);
} else {
$this->onRequest($connection, $data);
}
} elseif ($data instanceof Frame) {
$this->onMessage($connection, $data);
}
};
$server->onClose = function (TcpConnection $connection) {
if ($this->wsEnable) {
$this->onClose($connection);
}
};
$server->listen();
}
protected function preloadHttp()
{
$http = $this->app->http;
$this->app->invokeMethod([$http, 'loadMiddleware'], [], true);
if ($this->app->config->get('app.with_route', true)) {
$this->app->invokeMethod([$http, 'loadRoutes'], [], true);
$route = clone $this->app->route;
unset($this->app->route);
$this->app->resolving(WorkerHttp::class, function ($http, App $app) use ($route) {
$newRoute = clone $route;
$this->modifyProperty($newRoute, $app);
$app->instance('route', $newRoute);
});
}
$middleware = clone $this->app->middleware;
unset($this->app->middleware);
$this->app->resolving(WorkerHttp::class, function ($http, App $app) use ($middleware) {
$newMiddleware = clone $middleware;
$this->modifyProperty($newMiddleware, $app);
$app->instance('middleware', $newMiddleware);
});
unset($this->app->http);
$this->app->bind(Http::class, WorkerHttp::class);
}
public function onRequest(TcpConnection $connection, WorkerRequest $wkRequest)
{
$this->runInSandbox(function (Http $http, Event $event, WorkerApp $app) use ($connection, $wkRequest) {
$app->setInConsole(false);
$request = $this->prepareRequest($wkRequest);
try {
$response = $this->handleRequest($http, $request);
$this->prepareResponse($response);
} catch (Throwable $e) {
$handle = $app->make(Handle::class);
$handle->report($e);
$response = $handle->render($request, $e);
}
$this->sendResponse($connection, $request, $response, $app->cookie);
//关闭连接
$connection->close();
$http->end($response);
});
}
protected function handleRequest(Http $http, $request)
{
$level = ob_get_level();
ob_start();
$response = $http->run($request);
if (ob_get_length() > 0) {
$content = $response->getContent();
$response->content(ob_get_contents() . $content);
}
while (ob_get_level() > $level) {
ob_end_clean();
}
return $response;
}
protected function prepareRequest(WorkerRequest $wkRequest)
{
$header = $wkRequest->header();
$server = [];
foreach ($header as $key => $value) {
$server['http_' . str_replace('-', '_', $key)] = $value;
}
// 重新实例化请求对象 处理请求数据
/** @var \think\Request $request */
$request = $this->app->make('request', [], true);;
$queryString = $wkRequest->queryString();
return $request
->setMethod($wkRequest->method())
->withHeader($header)
->withServer($server)
->withGet($wkRequest->get())
->withPost($wkRequest->post())
->withCookie($wkRequest->cookie())
->withFiles($wkRequest->file())
->withInput($wkRequest->rawBody())
->setBaseUrl($wkRequest->uri())
->setUrl($wkRequest->uri() . (!empty($queryString) ? '?' . $queryString : ''))
->setPathinfo(ltrim($wkRequest->path(), '/'));
}
protected function prepareResponse(\think\Response $response)
{
switch (true) {
case $response instanceof View:
$response->getContent();
break;
}
}
protected function sendResponse(TcpConnection $connection, \think\Request $request, \think\Response $response, Cookie $cookie)
{
switch (true) {
case $response instanceof IteratorResponse:
$this->sendIterator($connection, $response, $cookie);
break;
case $response instanceof FileResponse:
$this->sendFile($connection, $request, $response, $cookie);
break;
default:
$this->sendContent($connection, $response, $cookie);
}
}
protected function sendIterator(TcpConnection $connection, IteratorResponse $response, Cookie $cookie)
{
$wkResponse = $this->createResponse($response, $cookie);
$connection->send($wkResponse);
foreach ($response as $content) {
$connection->send($content, true);
}
}
protected function sendFile(TcpConnection $connection, \think\Request $request, FileResponse $response, Cookie $cookie)
{
$ifNoneMatch = $request->header('If-None-Match');
$ifRange = $request->header('If-Range');
$code = $response->getCode();
$file = $response->getFile();
$eTag = $response->getHeader('ETag');
$lastModified = $response->getHeader('Last-Modified');
$fileSize = $file->getSize();
$offset = 0;
$length = -1;
if ($ifNoneMatch == $eTag) {
$code = 304;
} elseif (!$ifRange || $ifRange === $eTag || $ifRange === $lastModified) {
$range = $request->header('Range', '');
if (Str::startsWith($range, 'bytes=')) {
[$start, $end] = explode('-', substr($range, 6), 2) + [0];
$end = ('' === $end) ? $fileSize - 1 : (int) $end;
if ('' === $start) {
$start = $fileSize - $end;
$end = $fileSize - 1;
} else {
$start = (int) $start;
}
if ($start <= $end) {
$end = min($end, $fileSize - 1);
if ($start < 0 || $start > $end) {
$code = 416;
$response->header([
'Content-Range' => sprintf('bytes */%s', $fileSize),
]);
} elseif ($end - $start < $fileSize - 1) {
$length = $end < $fileSize ? $end - $start + 1 : -1;
$offset = $start;
$code = 206;
$response->header([
'Content-Range' => sprintf('bytes %s-%s/%s', $start, $end, $fileSize),
'Content-Length' => $end - $start + 1,
]);
}
}
}
}
$wkResponse = $this->createResponse($response, $cookie);
if ($code >= 200 && $code < 300 && $length !== 0) {
$wkResponse->withFile($file->getPathname(), $offset, $length);
}
$connection->send($wkResponse);
}
protected function sendContent(TcpConnection $connection, \think\Response $response, Cookie $cookie)
{
$response->header(['Transfer-Encoding' => 'chunked']);
$wkResponse = $this->createResponse($response, $cookie);
$connection->send($wkResponse);
$content = $response->getContent();
if ($content) {
$contentSize = strlen($content);
$chunkSize = 8192;
if ($contentSize > $chunkSize) {
$sendSize = 0;
do {
if (!$connection->send(new Chunk(substr($content, $sendSize, $chunkSize)))) {
break;
}
} while (($sendSize += $chunkSize) < $contentSize);
} else {
$connection->send(new Chunk($content));
}
}
$connection->send(new Chunk(''));
}
protected function createResponse(\think\Response $response, Cookie $cookie, $body = '')
{
$code = $response->getCode();
$header = $response->getHeader();
$wkResponse = new Response($code, $header, $body);
foreach ($cookie->getCookie() as $name => $val) {
[$value, $expire, $option] = $val;
$wkResponse->cookie($name, $value, $expire, $option['path'], $option['domain'], (bool) $option['secure'], (bool) $option['httponly'], $option['samesite']);
}
return $wkResponse;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace think\worker\concerns;
use think\helper\Arr;
use think\queue\event\JobFailed;
use think\queue\Worker;
use Workerman\Timer;
trait InteractsWithQueue
{
protected function createQueueWorkers()
{
$workers = $this->getConfig('queue.workers', []);
foreach ($workers as $queue => $options) {
if (strpos($queue, '@') !== false) {
[$queue, $connection] = explode('@', $queue);
} else {
$connection = null;
}
$workerNum = Arr::get($options, 'worker_num', 1);
$this->addWorker(function () use ($options, $connection, $queue) {
$delay = Arr::get($options, 'delay', 0);
$sleep = Arr::get($options, 'sleep', 3);
$tries = Arr::get($options, 'tries', 0);
$timeout = Arr::get($options, 'timeout', 60);
$qWorker = $this->app->make(Worker::class);
if ($this->supportsAsyncSignals()) {
pcntl_signal(SIGALRM, function () {
\think\worker\Worker::stopAll();
});
pcntl_alarm($timeout);
}
$this->createRunTimer(function () use ($connection, $queue, $delay, $sleep, $tries, $qWorker) {
$this->runInSandbox(function () use ($connection, $queue, $delay, $sleep, $tries, $qWorker) {
$qWorker->runNextJob($connection, $queue, $delay, $sleep, $tries);
});
if ($this->supportsAsyncSignals()) {
pcntl_alarm(0);
}
});
}, "queue [$queue]", $workerNum);
}
}
protected function supportsAsyncSignals()
{
return extension_loaded('pcntl');
}
protected function createRunTimer($func)
{
Timer::add(0.1, function () use ($func) {
$func();
$this->createRunTimer($func);
}, [], false);
}
protected function prepareQueue()
{
if ($this->getConfig('queue.enable', false)) {
$this->listenForEvents();
$this->createQueueWorkers();
}
}
/**
* 注册事件
*/
protected function listenForEvents()
{
$this->container->event->listen(JobFailed::class, function (JobFailed $event) {
$this->logFailedJob($event);
});
}
/**
* 记录失败任务
* @param JobFailed $event
*/
protected function logFailedJob(JobFailed $event)
{
$this->container['queue.failer']->log(
$event->connection,
$event->job->getQueue(),
$event->job->getRawBody(),
$event->exception
);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\worker\Ipc;
use think\worker\Watcher;
use think\worker\Worker;
use Workerman\Redis\Client;
/**
* Trait InteractsWithServer
* @property App $container
*/
trait InteractsWithServer
{
/** @var Ipc */
protected $ipc;
protected $workerId = null;
protected $stopping = false;
public function addWorker(callable $func, $name = 'none', int $count = 1): Worker
{
$worker = new Worker();
$worker->name = $name;
$worker->count = $count;
$worker->onWorkerStart = function (Worker $worker) use ($func) {
$this->clearCache();
$this->prepareApplication();
$this->conduit->connect();
$this->workerId = $this->ipc->listenMessage();
$this->triggerEvent('workerStart', $worker);
$func($worker);
};
$worker->onWorkerReload = function () {
$this->stopping = true;
};
return $worker;
}
public function getWorkerId()
{
return $this->workerId;
}
public function isStopping()
{
return $this->stopping;
}
/**
* 启动服务
*/
public function start(): void
{
$this->initialize();
$this->prepareIpc();
$this->triggerEvent('init');
//热更新
if ($this->getConfig('hot_update.enable', false)) {
$this->addHotUpdateWorker();
}
Worker::runAll();
}
protected function prepareIpc()
{
$this->ipc = $this->container->make(Ipc::class);
}
public function sendMessage($workerId, $message)
{
$this->ipc->sendMessage($workerId, $message);
}
/**
* 热更新
*/
protected function addHotUpdateWorker()
{
$worker = new Worker();
$worker->name = 'hot update';
$worker->reloadable = false;
$worker->onWorkerStart = function () {
$watcher = $this->container->make(Watcher::class);
$watcher->watch(function () {
posix_kill(posix_getppid(), SIGUSR1);
});
};
}
/**
* 清除apc、op缓存
*/
protected function clearCache()
{
if (extension_loaded('apc')) {
apc_clear_cache();
}
if (extension_loaded('Zend OPcache')) {
opcache_reset();
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\Event;
use think\helper\Arr;
use think\Http;
use think\worker\message\PushMessage;
use think\worker\Websocket;
use think\worker\contract\websocket\HandlerInterface;
use think\worker\websocket\Frame;
use think\worker\websocket\Handler;
use Throwable;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http\Request as WorkerRequest;
use Workerman\Protocols\Http\Response;
trait InteractsWithWebsocket
{
protected $messageSender = [];
protected function prepareWebsocket()
{
$this->onEvent('workerStart', function () {
$handlerClass = $this->getConfig('websocket.handler', Handler::class);
$this->app->bind(HandlerInterface::class, $handlerClass);
$this->onEvent('message', function ($message) {
if ($message instanceof PushMessage) {
if (isset($this->messageSender[$message->to])) {
$this->messageSender[$message->to]($message->data);
}
}
});
});
}
public function onHandShake(TcpConnection $connection, WorkerRequest $wkRequest)
{
$this->runInSandbox(function (App $app, Http $http, Event $event) use ($connection, $wkRequest) {
$request = $this->prepareRequest($wkRequest);
$response = $http->run($request);
if (!$response instanceof \think\worker\response\Websocket) {
$connection->close();
return;
}
$event->subscribe([$response]);
$this->upgrade($connection, $wkRequest);
$websocket = $app->make(Websocket::class, [$connection], true);
$app->instance(Websocket::class, $websocket);
$id = "{$this->workerId}.{$connection->id}";
$websocket->setSender($id);
$websocket->join($id);
$handler = $app->make(HandlerInterface::class);
$this->messageSender[$connection->id] = function ($data) use ($connection, $handler) {
$connection->send($handler->encodeMessage($data));
};
try {
$handler->onOpen($request);
} catch (Throwable $e) {
$this->logServerError($e);
}
}, $connection);
}
public function onMessage(TcpConnection $connection, Frame $frame)
{
$this->runInSandbox(function (App $app) use ($frame) {
$handler = $app->make(HandlerInterface::class);
try {
$handler->onMessage($frame);
} catch (Throwable $e) {
$this->logServerError($e);
}
}, $connection);
}
public function onClose(TcpConnection $connection)
{
$this->runInSandbox(function (App $app) use ($connection) {
if ($app->exists(Websocket::class)) {
$websocket = $app->make(Websocket::class);
$handler = $app->make(HandlerInterface::class);
try {
$handler->onClose();
} catch (Throwable $e) {
$this->logServerError($e);
}
// leave all rooms
$websocket->leave();
unset($this->messageSender[$connection->id]);
$websocket->setConnected(false);
}
}, $connection);
}
protected function isWebsocketRequest(WorkerRequest $request)
{
$header = $request->header();
return strcasecmp(Arr::get($header, 'connection', ''), 'upgrade') === 0 &&
strcasecmp(Arr::get($header, 'upgrade', ''), 'websocket') === 0;
}
protected function upgrade(TcpConnection $connection, WorkerRequest $request)
{
$key = $request->header('Sec-WebSocket-Key');
$headers = [
'Upgrade' => 'websocket',
'Sec-WebSocket-Version' => '13',
'Connection' => 'Upgrade',
'Sec-WebSocket-Accept' => base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)),
];
if ($protocol = $request->header('Sec-Websocket-Protocol')) {
$headers['Sec-WebSocket-Protocol'] = $protocol;
}
$response = new Response(101, $headers);
$connection->send($response);
// Websocket data buffer.
$connection->context->websocketDataBuffer = '';
// Current websocket frame length.
$connection->context->websocketCurrentFrameLength = 0;
// Current websocket frame data.
$connection->context->websocketCurrentFrameBuffer = '';
$connection->context->websocketHandshake = true;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace think\worker\concerns;
use ReflectionObject;
trait ModifyProperty
{
protected function modifyProperty($object, $value, $property = 'app')
{
$reflectObject = new ReflectionObject($object);
if ($reflectObject->hasProperty($property)) {
$reflectProperty = $reflectObject->getProperty($property);
$reflectProperty->setAccessible(true);
$reflectProperty->setValue($object, $value);
}
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace think\worker\concerns;
use Closure;
use think\App;
use think\worker\App as WorkerApp;
use think\worker\Manager;
use think\worker\Sandbox;
use Throwable;
/**
* Trait WithApplication
* @property App $container
*/
trait WithApplication
{
/**
* @var WorkerApp
*/
protected $app;
protected function prepareApplication()
{
if (!$this->app instanceof WorkerApp) {
$this->app = new WorkerApp($this->container->getRootPath());
$this->app->bind(WorkerApp::class, App::class);
$this->app->bind(Manager::class, $this);
$this->app->initialize();
$this->app->instance('request', $this->container->request);
$this->prepareConcretes();
}
}
/**
* 预加载
*/
protected function prepareConcretes()
{
$defaultConcretes = ['db', 'cache', 'event'];
$concretes = array_merge($defaultConcretes, $this->getConfig('concretes', []));
foreach ($concretes as $concrete) {
$this->app->make($concrete);
}
}
public function getApp()
{
return $this->app;
}
/**
* 获取沙箱
* @return Sandbox
*/
protected function getSandbox()
{
return $this->app->make(Sandbox::class);
}
/**
* 在沙箱中执行
* @param Closure $callable
*/
public function runInSandbox(Closure $callable, ?object $key = null)
{
try {
$this->getSandbox()->run($callable, $key);
} catch (Throwable $e) {
$this->logServerError($e);
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace think\worker\concerns;
use think\App;
use think\exception\Handle;
use Throwable;
trait WithContainer
{
/**
* @var App
*/
protected $container;
/**
* Manager constructor.
* @param App $container
*/
public function __construct(App $container)
{
$this->container = $container;
}
protected function getContainer()
{
return $this->container;
}
/**
* 获取配置
* @param string $name
* @param mixed $default
* @return mixed
*/
public function getConfig(string $name, $default = null)
{
return $this->container->config->get("worker.{$name}", $default);
}
/**
* 触发事件
* @param string $event
* @param mixed $params
*/
public function triggerEvent(string $event, $params = null): void
{
$this->container->event->trigger("worker.{$event}", $params);
}
/**
* 监听事件
* @param string $event
* @param $listener
* @param bool $first
*/
public function onEvent(string $event, $listener, bool $first = false): void
{
$this->container->event->listen("worker.{$event}", $listener, $first);
}
/**
* Log server error.
*
* @param Throwable $e
*/
public function logServerError(Throwable $e)
{
/** @var Handle $handle */
$handle = $this->container->make(Handle::class);
$handle->report($e);
}
}

26
src/conduit/Driver.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace think\worker\conduit;
abstract class Driver
{
abstract public function prepare();
abstract public function connect();
abstract public function get(string $name);
abstract public function set(string $name, $value);
abstract public function inc(string $name, int $step = 1);
abstract public function sAdd(string $name, ...$value);
abstract public function sRem(string $name, $value);
abstract public function sMembers(string $name);
abstract public function publish(string $name, $value);
abstract public function subscribe(string $name, $callback);
}

View File

@@ -0,0 +1,186 @@
<?php
namespace think\worker\conduit\driver;
use Exception;
use Revolt\EventLoop;
use Revolt\EventLoop\Suspension;
use think\worker\conduit\Driver;
use think\worker\conduit\driver\socket\Command;
use think\worker\conduit\driver\socket\Event;
use think\worker\conduit\driver\socket\Result;
use think\worker\conduit\driver\socket\Server;
use think\worker\Manager;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Protocols\Frame;
use Workerman\Timer;
class Socket extends Driver
{
protected $id = 0;
protected $domain;
/** @var AsyncTcpConnection|null */
protected $connection = null;
protected $reconnectTimer;
protected $pingInterval = 55;
/** @var array<int, array{0: Suspension, 1: int}> */
protected $suspensions = [];
protected $events = [];
public function __construct(protected Manager $manager)
{
$filename = runtime_path() . 'conduit.sock';
@unlink($filename);
$this->domain = "unix://{$filename}";
}
public function prepare()
{
//启动服务端
Server::run($this->domain);
}
public function connect()
{
$suspension = EventLoop::getSuspension();
$this->connection = $this->createConnection($suspension);
$suspension->suspend();
Timer::add($this->pingInterval, function () {
if ($this->connection) {
$this->connection->send('');
}
});
Timer::add(1, function () {
//检查是否超时
foreach ($this->suspensions as $id => $suspension) {
if (time() - $suspension[1] > 10) {
$suspension[0]->throw(new Exception('conduit connection is timeout'));
unset($this->suspensions[$id]);
}
}
});
}
public function get(string $name)
{
return $this->sendAndRecv(Command::create('get', $name));
}
public function set(string $name, $value)
{
$this->send(Command::create('set', $name, $value));
}
public function inc(string $name, int $step = 1)
{
return $this->sendAndRecv(Command::create('inc', $name, $step));
}
public function sAdd(string $name, ...$value)
{
$this->send(Command::create('sAdd', $name, $value));
}
public function sRem(string $name, $value)
{
$this->send(Command::create('sRem', $name, $value));
}
public function sMembers(string $name)
{
return $this->sendAndRecv(Command::create('sMembers', $name));
}
public function publish(string $name, $value)
{
$this->send(Command::create('publish', $name, $value));
}
public function subscribe(string $name, $callback)
{
$this->send(Command::create('subscribe', $name));
$this->events[$name] = $callback;
}
protected function sendAndRecv(Command $command)
{
$suspension = EventLoop::getSuspension();
$id = $this->id++;
$command->id = $id;
$this->suspensions[$id] = [$suspension, time()];
$this->send($command);
return $suspension->suspend();
}
protected function send(Command $command)
{
if (!$this->connection) {
throw new Exception('conduit connection is disconnected');
}
$this->connection->send(serialize($command));
}
protected function createConnection(?Suspension $suspension = null)
{
$connection = new AsyncTcpConnection($this->domain);
$connection->protocol = Frame::class;
$connection->onConnect = function () use ($suspension) {
$this->clearTimer();
if ($suspension) {
$suspension->resume();
}
//补订阅
foreach ($this->events as $name => $callback) {
$this->send(Command::create('subscribe', $name));
}
};
$connection->onMessage = function ($connection, $buffer) {
/** @var Result|Event $result */
$result = unserialize($buffer);
if ($result instanceof Event) {
if (isset($this->events[$result->name])) {
$this->events[$result->name]($result->data);
}
} elseif (isset($result->id) && isset($this->suspensions[$result->id])) {
[$suspension] = $this->suspensions[$result->id];
$suspension->resume($result->data);
unset($this->suspensions[$result->id]);
}
};
$connection->onClose = function () {
$this->connection = null;
//重连
$this->clearTimer();
$this->reconnectTimer = Timer::add(1, function () {
$this->connection = $this->createConnection();
});
};
$connection->connect();
return $connection;
}
protected function clearTimer()
{
if ($this->reconnectTimer) {
Timer::del($this->reconnectTimer);
$this->reconnectTimer = null;
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker\conduit\driver\socket;
class Command
{
public $id;
public $name;
public $key;
public $data;
public static function create($name, $key, $data = null)
{
$packet = new self();
$packet->name = $name;
$packet->key = $key;
$packet->data = $data;
return $packet;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace think\worker\conduit\driver\socket;
class Event
{
public function __construct(public $name, public $data)
{
}
public static function create($name, $data)
{
return new self($name, $data);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace think\worker\conduit\driver\socket;
class Result
{
public function __construct(public $id = null, public $data = null)
{
}
public static function create($id = null)
{
return new self($id);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace think\worker\conduit\driver\socket;
use think\worker\Worker;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Frame;
class Server
{
protected $data = [];
/** @var array<string,TcpConnection[]> */
protected $subscribers = [];
public function onMessage(TcpConnection $connection, $buffer)
{
if (empty($buffer)) {
return;
}
/** @var Command $command */
$command = unserialize($buffer);
$result = Result::create($command->id);
switch ($command->name) {
case 'get':
$result->data = $this->data[$command->key] ?? null;
break;
case 'set':
$this->data[$command->key] = $command->data;
break;
case 'inc':
if (!isset($this->data[$command->key]) || !is_integer($this->data[$command->key])) {
$this->data[$command->key] = 0;
}
$result->data = $this->data[$command->key] += $command->data ?? 1;
break;
case 'sAdd':
if (!isset($this->data[$command->key]) || !is_array($this->data[$command->key])) {
$this->data[$command->key] = [];
}
$this->data[$command->key] = array_merge($this->data[$command->key], $command->data);
break;
case 'sRem':
if (!isset($this->data[$command->key]) || !is_array($this->data[$command->key])) {
$this->data[$command->key] = [];
}
$this->data[$command->key] = array_diff($this->data[$command->key], [$command->data]);
break;
case 'sMembers':
if (!isset($this->data[$command->key]) || !is_array($this->data[$command->key])) {
$this->data[$command->key] = [];
}
$result->data = $this->data[$command->key];
break;
case 'subscribe':
if (!isset($this->subscribers[$command->key])) {
$this->subscribers[$command->key] = [];
}
$this->subscribers[$command->key][] = $connection;
break;
case 'publish':
if (!empty($this->subscribers[$command->key])) {
foreach ($this->subscribers[$command->key] as $conn) {
$conn->send(serialize(Event::create($command->key, $command->data)));
}
}
break;
}
if (isset($result->id)) {
$connection->send(serialize($result));
}
}
public function onClose(TcpConnection $connection)
{
if (!empty($this->subscribers)) {
foreach ($this->subscribers as $key => $connections) {
$this->subscribers[$key] = array_udiff($connections, [$connection], function ($a, $b) {
return $a <=> $b;
});
}
}
}
public static function run($domain)
{
//启动服务端
$server = new self();
$worker = new Worker($domain);
$worker->name = 'conduit';
$worker->protocol = Frame::class;
$worker->reloadable = false;
$worker->onMessage = [$server, 'onMessage'];
$worker->onClose = [$server, 'onClose'];
}
}

39
src/config/worker.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006-2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
use think\worker\websocket\Handler;
return [
'http' => [
'enable' => true,
'host' => '0.0.0.0',
'port' => 8080,
'worker_num' => 4,
'options' => [],
],
'websocket' => [
'enable' => false,
'handler' => Handler::class,
'ping_interval' => 25000,
'ping_timeout' => 60000,
],
//队列
'queue' => [
'enable' => false,
'workers' => [],
],
'hot_update' => [
'enable' => env('APP_DEBUG', false),
'name' => ['*.php'],
'include' => [app_path(), config_path(), root_path('route')],
'exclude' => [],
],
];

View File

@@ -0,0 +1,17 @@
<?php
namespace think\worker\contract;
use think\App;
use think\worker\Sandbox;
interface ResetterInterface
{
/**
* "handle" function for resetting app.
*
* @param \think\App $app
* @param Sandbox $sandbox
*/
public function handle(App $app, Sandbox $sandbox);
}

View File

@@ -0,0 +1,31 @@
<?php
namespace think\worker\contract\websocket;
use think\Request;
use think\worker\websocket\Frame;
interface HandlerInterface
{
/**
* "onOpen" listener.
*
* @param Request $request
*/
public function onOpen(Request $request);
/**
* "onMessage" listener.
*
* @param Frame $frame
*/
public function onMessage(Frame $frame);
/**
* "onClose" listener.
*/
public function onClose();
public function encodeMessage($message);
}

View File

@@ -0,0 +1,15 @@
<?php
namespace think\worker\message;
class PushMessage
{
public $to;
public $data;
public function __construct($to, $data)
{
$this->to = $to;
$this->data = $data;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace think\worker\protocols;
use think\worker\websocket\Frame;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http;
use Workerman\Protocols\Websocket;
class FlexHttp
{
public static function input(string $buffer, TcpConnection $connection)
{
if (empty($connection->context->websocketHandshake)) {
return Http::input($buffer, $connection);
} else {
return Websocket::input($buffer, $connection);
}
}
public static function decode(string $buffer, TcpConnection $connection)
{
if (empty($connection->context->websocketHandshake)) {
return Http::decode($buffer, $connection);
} else {
$data = Websocket::decode($buffer, $connection);
return new Frame($connection->id, $data);
}
}
public static function encode(mixed $response, TcpConnection $connection): string
{
if (empty($connection->context->websocketHandshake)) {
return Http::encode($response, $connection);
} else {
return Websocket::encode($response, $connection);
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ClearInstances implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
$instances = ['log', 'session', 'view', 'response', 'cookie'];
$instances = array_merge($instances, $sandbox->getConfig()->get('worker.instances', []));
foreach ($instances as $instance) {
$app->delete($instance);
}
return $app;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetConfig implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
$app->instance('config', clone $sandbox->getConfig());
return $app;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\concerns\ModifyProperty;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetEvent implements ResetterInterface
{
use ModifyProperty;
public function handle(App $app, Sandbox $sandbox)
{
$event = clone $sandbox->getEvent();
$this->modifyProperty($event, $app);
$app->instance('event', $event);
return $app;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\Model;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetModel implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
if (class_exists(Model::class)) {
Model::setInvoker(function (...$args) use ($sandbox) {
return $sandbox->getSnapshot()->invoke(...$args);
});
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\Paginator;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetPaginator implements ResetterInterface
{
public function handle(App $app, Sandbox $sandbox)
{
Paginator::currentPathResolver(function () use ($sandbox) {
return $sandbox->getSnapshot()->request->baseUrl();
});
Paginator::currentPageResolver(function ($varPage = 'page') use ($sandbox) {
$page = $sandbox->getSnapshot()->request->param($varPage);
if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) {
return (int) $page;
}
return 1;
});
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace think\worker\resetters;
use think\App;
use think\worker\concerns\ModifyProperty;
use think\worker\contract\ResetterInterface;
use think\worker\Sandbox;
class ResetService implements ResetterInterface
{
use ModifyProperty;
/**
* "handle" function for resetting app.
*
* @param App $app
* @param Sandbox $sandbox
*/
public function handle(App $app, Sandbox $sandbox)
{
foreach ($sandbox->getServices() as $service) {
$this->modifyProperty($service, $app);
if (method_exists($service, 'register')) {
$service->register();
}
if (method_exists($service, 'boot')) {
$app->invoke([$service, 'boot']);
}
}
}
}

112
src/response/File.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace think\worker\response;
use DateTime;
use RuntimeException;
use SplFileInfo;
use think\Response;
class File extends Response
{
public const DISPOSITION_ATTACHMENT = 'attachment';
public const DISPOSITION_INLINE = 'inline';
protected $header = [
'Content-Type' => 'application/octet-stream',
'Accept-Ranges' => 'bytes',
];
/**
* @var SplFileInfo
*/
protected $file;
public function __construct($file, ?string $contentDisposition = null, bool $autoEtag = true, bool $autoLastModified = true, bool $autoContentType = true)
{
$this->setFile($file, $contentDisposition, $autoEtag, $autoLastModified, $autoContentType);
}
public function getFile()
{
return $this->file;
}
public function setFile($file, ?string $contentDisposition = null, bool $autoEtag = true, bool $autoLastModified = true, bool $autoContentType = true)
{
if (!$file instanceof SplFileInfo) {
$file = new SplFileInfo((string) $file);
}
if (!$file->isReadable()) {
throw new RuntimeException('File must be readable.');
}
$this->header['Content-Length'] = $file->getSize();
$this->file = $file;
if ($autoEtag) {
$this->setAutoEtag();
}
if ($autoLastModified) {
$this->setAutoLastModified();
}
if ($contentDisposition) {
$this->setContentDisposition($contentDisposition);
}
if ($autoContentType) {
$this->setAutoContentType();
}
return $this;
}
public function setAutoContentType()
{
if (extension_loaded('fileinfo')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $this->file->getPathname());
if ($mimeType) {
$this->header['Content-Type'] = $mimeType;
}
}
}
public function setContentDisposition(string $disposition, string $filename = '')
{
if ('' === $filename) {
$filename = $this->file->getFilename();
}
$this->header['Content-Disposition'] = "{$disposition}; filename=\"{$filename}\"";
return $this;
}
public function setAutoLastModified()
{
$mTime = $this->file->getMTime();
if ($mTime) {
$date = DateTime::createFromFormat('U', (string) $mTime);
$this->lastModified($date->format('D, d M Y H:i:s') . ' GMT');
}
return $this;
}
public function setAutoEtag()
{
$eTag = "W/\"" . sha1_file($this->file->getPathname()) . "\"";
return $this->eTag($eTag);
}
protected function sendData(string $data): void
{
readfile($this->file->getPathname());
}
}

22
src/response/Iterator.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace think\worker\response;
use IteratorAggregate;
use think\Response;
use Traversable;
class Iterator extends Response implements IteratorAggregate
{
protected $iterator;
public function __construct(Traversable $iterator)
{
$this->iterator = $iterator;
}
public function getIterator(): Traversable
{
return $this->iterator;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace think\worker\response;
use think\Event;
use think\Response;
class Websocket extends Response
{
protected $listeners = [];
public function onOpen($listener)
{
$this->listeners['Open'] = $listener;
return $this;
}
public function onMessage($listener)
{
$this->listeners['Message'] = $listener;
return $this;
}
public function onEvent($listener)
{
$this->listeners['Event'] = $listener;
return $this;
}
public function onClose($listener)
{
$this->listeners['Close'] = $listener;
return $this;
}
public function onConnect($listener)
{
$this->listeners['Connect'] = $listener;
return $this;
}
public function onDisconnect($listener)
{
$this->listeners['Disconnect'] = $listener;
return $this;
}
public function onPing($listener)
{
$this->listeners['Ping'] = $listener;
return $this;
}
public function onPong($listener)
{
$this->listeners['Pong'] = $listener;
return $this;
}
public function subscribe(Event $event)
{
foreach ($this->listeners as $eventName => $listener) {
$event->listen('worker.websocket.' . $eventName, $listener);
}
}
}

8
src/watcher/Driver.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace think\worker\watcher;
interface Driver
{
public function watch(callable $callback);
}

72
src/watcher/Find.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
namespace think\worker\watcher;
use Symfony\Component\Process\Process;
use Workerman\Timer;
class Find implements Driver
{
protected $name;
protected $directory;
protected $exclude;
public function __construct($directory, $exclude, $name)
{
$this->directory = $directory;
$this->exclude = $exclude;
$this->name = $name;
}
public function watch(callable $callback)
{
$ms = 2000;
$seconds = ceil(($ms + 1000) / 1000);
$minutes = sprintf('-%.2f', $seconds / 60);
$dest = implode(' ', $this->directory);
$name = empty($this->name) ? '' : ' \( ' . join(' -o ', array_map(fn($v) => "-name \"{$v}\"", $this->name)) . ' \)';
$notName = '';
$notPath = '';
if (!empty($this->exclude)) {
$excludeDirs = $excludeFiles = [];
foreach ($this->exclude as $directory) {
$directory = rtrim($directory, '/');
if (is_dir($directory)) {
$excludeDirs[] = $directory;
} else {
$excludeFiles[] = $directory;
}
}
if (!empty($excludeFiles)) {
$notPath = ' -not \( ' . join(' -and ', array_map(fn($v) => "-name \"{$v}\"", $excludeFiles)) . ' \)';
}
if (!empty($excludeDirs)) {
$notPath = ' -not \( ' . join(' -and ', array_map(fn($v) => "-path \"{$v}/*\"", $excludeDirs)) . ' \)';
}
}
$command = "find {$dest}{$name}{$notName}{$notPath} -mmin {$minutes} -type f -print";
Timer::add($ms / 1000, function () use ($callback, $command) {
$stdout = $this->exec($command);
if (!empty($stdout)) {
call_user_func($callback);
}
});
}
public function exec($command)
{
$process = Process::fromShellCommandline($command);
$process->run();
if ($process->isSuccessful()) {
return $process->getOutput();
}
return false;
}
}

52
src/watcher/Scan.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace think\worker\watcher;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Workerman\Timer;
class Scan implements Driver
{
protected $finder;
protected $files = [];
public function __construct($directory, $exclude, $name)
{
$this->finder = new Finder();
$this->finder
->files()
->name($name)
->in($directory)
->exclude($exclude);
}
protected function findFiles()
{
$files = [];
/** @var SplFileInfo $f */
foreach ($this->finder as $f) {
$files[$f->getRealpath()] = $f->getMTime();
}
return $files;
}
public function watch(callable $callback)
{
$this->files = $this->findFiles();
Timer::add(2, function () use ($callback) {
$files = $this->findFiles();
foreach ($files as $path => $time) {
if (empty($this->files[$path]) || $this->files[$path] != $time) {
call_user_func($callback);
break;
}
}
$this->files = $files;
});
}
}

15
src/websocket/Event.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
namespace think\worker\websocket;
class Event
{
public $type;
public $data;
public function __construct($type, $data = null)
{
$this->type = $type;
$this->data = $data;
}
}

10
src/websocket/Frame.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace think\worker\websocket;
class Frame
{
public function __construct(public int $fd, public string $data)
{
}
}

71
src/websocket/Handler.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace think\worker\websocket;
use think\Event;
use think\Request;
use think\worker\contract\websocket\HandlerInterface;
use think\worker\websocket\Event as WsEvent;
class Handler implements HandlerInterface
{
protected $event;
public function __construct(Event $event)
{
$this->event = $event;
}
/**
* "onOpen" listener.
*
* @param Request $request
*/
public function onOpen(Request $request)
{
$this->event->trigger('worker.websocket.Open', $request);
}
/**
* "onMessage" listener.
*
* @param Frame $frame
*/
public function onMessage(Frame $frame)
{
$this->event->trigger('worker.websocket.Message', $frame);
$event = $this->decode($frame->data);
if ($event) {
$this->event->trigger('worker.websocket.Event', $event);
}
}
/**
* "onClose" listener.
*/
public function onClose()
{
$this->event->trigger('worker.websocket.Close');
}
protected function decode($payload)
{
$data = json_decode($payload, true);
if (!empty($data['type'])) {
return new WsEvent($data['type'], $data['data'] ?? null);
}
return null;
}
public function encodeMessage($message)
{
if ($message instanceof WsEvent) {
return json_encode([
'type' => $message->type,
'data' => $message->data,
]);
}
return $message;
}
}

67
src/websocket/Pusher.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace think\worker\websocket;
use think\worker\Manager;
use think\worker\message\PushMessage;
/**
* Class Pusher
*/
class Pusher
{
/** @var Room */
protected $room;
/** @var Manager */
protected $manager;
protected $to = [];
public function __construct(Manager $manager, Room $room)
{
$this->manager = $manager;
$this->room = $room;
}
public function to(...$values)
{
foreach ($values as $value) {
if (is_array($value)) {
$this->to(...$value);
} elseif (!in_array($value, $this->to)) {
$this->to[] = $value;
}
}
return $this;
}
/**
* Push message to related descriptors
* @param $data
* @return void
*/
public function push($data): void
{
$fds = [];
foreach ($this->to as $room) {
$clients = $this->room->getClients($room);
if (!empty($clients)) {
$fds = array_merge($fds, $clients);
}
}
foreach (array_unique($fds) as $fd) {
[$workerId, $fd] = explode('.', $fd);
$this->manager->sendMessage((int) $workerId, new PushMessage((int) $fd, $data));
}
}
public function emit(string $event, ...$data): void
{
$this->push(new Event($event, $data));
}
}

54
src/websocket/Room.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace think\worker\websocket;
use think\worker\Conduit;
class Room
{
public function __construct(protected Conduit $conduit)
{
}
public function add($fd, ...$rooms)
{
$this->conduit->sAdd($this->getClientKey($fd), ...$rooms);
foreach ($rooms as $room) {
$this->conduit->sAdd($this->getRoomKey($room), $fd);
}
}
public function delete($fd, ...$rooms)
{
$rooms = count($rooms) ? $rooms : $this->getRooms($fd);
$this->conduit->sRem($this->getClientKey($fd), ...$rooms);
foreach ($rooms as $room) {
$this->conduit->sRem($this->getRoomKey($room), $fd);
}
}
public function getClients(string $room)
{
return $this->conduit->sMembers($this->getRoomKey($room)) ?: [];
}
public function getRooms(string $fd)
{
return $this->conduit->sMembers($this->getClientKey($fd)) ?: [];
}
protected function getClientKey(string $key)
{
return "ws:client:{$key}";
}
protected function getRoomKey($room)
{
return "ws:room:{$room}";
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace think\worker\websocket\socketio;
class EnginePacket
{
/**
* Engine.io packet type `open`.
*/
const OPEN = 0;
/**
* Engine.io packet type `close`.
*/
const CLOSE = 1;
/**
* Engine.io packet type `ping`.
*/
const PING = 2;
/**
* Engine.io packet type `pong`.
*/
const PONG = 3;
/**
* Engine.io packet type `message`.
*/
const MESSAGE = 4;
/**
* Engine.io packet type 'upgrade'
*/
const UPGRADE = 5;
/**
* Engine.io packet type `noop`.
*/
const NOOP = 6;
public $type;
public $data = '';
public function __construct($type, $data = '')
{
$this->type = $type;
$this->data = $data;
}
public static function open($payload)
{
return new self(self::OPEN, $payload);
}
public static function pong($payload = '')
{
return new self(self::PONG, $payload);
}
public static function ping()
{
return new self(self::PING);
}
public static function message($payload)
{
return new self(self::MESSAGE, $payload);
}
public static function fromString(string $packet)
{
return new self(substr($packet, 0, 1), substr($packet, 1) ?: '');
}
public function toString()
{
return $this->type . $this->data;
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace think\worker\websocket\socketio;
use Exception;
use think\Config;
use think\Event;
use think\Request;
use think\worker\contract\websocket\HandlerInterface;
use think\worker\Websocket;
use think\worker\websocket\Event as WsEvent;
use think\worker\websocket\Frame;
use Workerman\Timer;
class Handler implements HandlerInterface
{
/** @var Config */
protected $config;
protected $event;
protected $websocket;
protected $eio;
protected $pingTimeoutTimer = 0;
protected $pingIntervalTimer = 0;
protected $pingInterval;
protected $pingTimeout;
public function __construct(Event $event, Config $config, Websocket $websocket)
{
$this->event = $event;
$this->config = $config;
$this->websocket = $websocket;
$this->pingInterval = $this->config->get('worker.websocket.ping_interval', 25000);
$this->pingTimeout = $this->config->get('worker.websocket.ping_timeout', 60000);
}
/**
* "onOpen" listener.
*
* @param Request $request
*/
public function onOpen(Request $request)
{
$this->eio = $request->param('EIO');
$payload = json_encode(
[
'sid' => base64_encode(uniqid()),
'upgrades' => [],
'pingInterval' => $this->pingInterval,
'pingTimeout' => $this->pingTimeout,
]
);
$this->push(EnginePacket::open($payload));
$this->event->trigger('worker.websocket.Open', $request);
if ($this->eio < 4) {
$this->resetPingTimeout($this->pingInterval + $this->pingTimeout);
$this->onConnect();
} else {
$this->schedulePing();
}
}
/**
* "onMessage" listener.
*
* @param Frame $frame
*/
public function onMessage(Frame $frame)
{
$enginePacket = EnginePacket::fromString($frame->data);
$this->event->trigger('worker.websocket.Message', $enginePacket);
$this->resetPingTimeout($this->pingInterval + $this->pingTimeout);
switch ($enginePacket->type) {
case EnginePacket::MESSAGE:
$packet = Packet::fromString($enginePacket->data);
switch ($packet->type) {
case Packet::CONNECT:
$this->onConnect($packet->data);
break;
case Packet::EVENT:
$type = array_shift($packet->data);
$data = $packet->data;
$result = $this->event->trigger('worker.websocket.Event', new WsEvent($type, $data));
if ($packet->id !== null) {
$responsePacket = Packet::create(Packet::ACK, [
'id' => $packet->id,
'nsp' => $packet->nsp,
'data' => $result,
]);
$this->push($responsePacket);
}
break;
case Packet::DISCONNECT:
$this->event->trigger('worker.websocket.Disconnect');
$this->websocket->close();
break;
default:
$this->websocket->close();
break;
}
break;
case EnginePacket::PING:
$this->event->trigger('worker.websocket.Ping');
$this->push(EnginePacket::pong($enginePacket->data));
break;
case EnginePacket::PONG:
$this->event->trigger('worker.websocket.Pong');
$this->schedulePing();
break;
default:
$this->websocket->close();
break;
}
}
/**
* "onClose" listener.
*/
public function onClose()
{
Timer::del($this->pingTimeoutTimer);
Timer::del($this->pingIntervalTimer);
$this->event->trigger('worker.websocket.Close');
}
protected function onConnect($data = null)
{
try {
$this->event->trigger('worker.websocket.Connect', $data);
$packet = Packet::create(Packet::CONNECT);
if ($this->eio >= 4) {
$packet->data = ['sid' => base64_encode(uniqid())];
}
} catch (Exception $exception) {
$packet = Packet::create(Packet::CONNECT_ERROR, [
'data' => ['message' => $exception->getMessage()],
]);
}
$this->push($packet);
}
protected function resetPingTimeout($timeout)
{
Timer::del($this->pingTimeoutTimer);
$this->pingTimeoutTimer = Timer::delay($timeout, function () {
$this->websocket->close();
});
}
protected function schedulePing()
{
Timer::del($this->pingIntervalTimer);
$this->pingIntervalTimer = Timer::delay($this->pingInterval, function () {
$this->push(EnginePacket::ping());
$this->resetPingTimeout($this->pingTimeout);
});
}
public function encodeMessage($message)
{
if ($message instanceof WsEvent) {
$message = Packet::create(Packet::EVENT, [
'data' => array_merge([$message->type], $message->data),
]);
}
if ($message instanceof Packet) {
$message = EnginePacket::message($message->toString());
}
if ($message instanceof EnginePacket) {
$message = $message->toString();
}
return $message;
}
protected function push($data)
{
$this->websocket->push($data);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace think\worker\websocket\socketio;
/**
* Class Packet
*/
class Packet
{
/**
* Socket.io packet type `connect`.
*/
const CONNECT = 0;
/**
* Socket.io packet type `disconnect`.
*/
const DISCONNECT = 1;
/**
* Socket.io packet type `event`.
*/
const EVENT = 2;
/**
* Socket.io packet type `ack`.
*/
const ACK = 3;
/**
* Socket.io packet type `connect_error`.
*/
const CONNECT_ERROR = 4;
/**
* Socket.io packet type 'binary event'
*/
const BINARY_EVENT = 5;
/**
* Socket.io packet type `binary ack`. For acks with binary arguments.
*/
const BINARY_ACK = 6;
public $type;
public $nsp = '/';
public $data = null;
public $id = null;
public function __construct(int $type)
{
$this->type = $type;
}
public static function create($type, array $decoded = [])
{
$new = new self($type);
$new->id = $decoded['id'] ?? null;
if (isset($decoded['nsp'])) {
$new->nsp = $decoded['nsp'] ?: '/';
} else {
$new->nsp = '/';
}
$new->data = $decoded['data'] ?? null;
return $new;
}
public function toString()
{
$str = '' . $this->type;
if ($this->nsp && '/' !== $this->nsp) {
$str .= $this->nsp . ',';
}
if ($this->id !== null) {
$str .= $this->id;
}
if (null !== $this->data) {
$str .= json_encode($this->data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return $str;
}
public static function fromString(string $str)
{
$i = 0;
$packet = new Packet((int) substr($str, 0, 1));
// look up namespace (if any)
if ('/' === substr($str, $i + 1, 1)) {
$nsp = '';
while (++$i) {
$c = substr($str, $i, 1);
if (',' === $c) {
break;
}
$nsp .= $c;
if ($i === strlen($str)) {
break;
}
}
$packet->nsp = $nsp;
} else {
$packet->nsp = '/';
}
// look up id
$next = substr($str, $i + 1, 1);
if ('' !== $next && is_numeric($next)) {
$id = '';
while (++$i) {
$c = substr($str, $i, 1);
if (null == $c || !is_numeric($c)) {
--$i;
break;
}
$id .= substr($str, $i, 1);
if ($i === strlen($str)) {
break;
}
}
$packet->id = intval($id);
}
// look up json data
if (substr($str, ++$i, 1)) {
$packet->data = json_decode(substr($str, $i), true);
}
return $packet;
}
}