diff --git a/composer.json b/composer.json index b1738e47..fefe47d5 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "illuminate/console": "~5.1", "illuminate/contracts": "~5.1", "illuminate/http": "~5.1", - "illuminate/support": "~5.1" + "illuminate/support": "~5.1", + "predis/predis": "^1.1" }, "require-dev": { "laravel/lumen-framework": "~5.1", @@ -36,5 +37,18 @@ "SwooleTW\\Http\\Tests\\": "tests", "SwooleTW\\Http\\Tests\\Fixtures\\Laravel\\App\\": "tests/fixtures/laravel/app" } + }, + "extra": { + "laravel": { + "providers": [ + "SwooleTW\\Http\\LaravelServiceProvider" + ], + "aliases": { + "Server": "SwooleTW\\Http\\Server\\Facades\\Server", + "Table": "SwooleTW\\Http\\Server\\Facades\\Table", + "Room": "SwooleTW\\Http\\Websocket\\Facades\\Room", + "Websocket": "SwooleTW\\Http\\Websocket\\Facades\\Websocket" + } + } } } diff --git a/config/swoole_http.php b/config/swoole_http.php index 45bd979f..13e76daa 100644 --- a/config/swoole_http.php +++ b/config/swoole_http.php @@ -8,7 +8,7 @@ | HTTP server configurations. |-------------------------------------------------------------------------- | - | @see https://wiki.swoole.com/wiki/page/274.html + | @see https://www.swoole.co.uk/docs/modules/swoole-server/configuration | */ 'server' => [ @@ -18,21 +18,60 @@ 'pid_file' => env('SWOOLE_HTTP_PID_FILE', base_path('storage/logs/swoole_http.pid')), 'log_file' => env('SWOOLE_HTTP_LOG_FILE', base_path('storage/logs/swoole_http.log')), 'daemonize' => env('SWOOLE_HTTP_DAEMONIZE', false), - 'task_worker_num' => env('SWOOLE_HTTP_TASK_WORKER_NUM', 4) + 'task_worker_num' => env('SWOOLE_HTTP_TASK_WORKER_NUM', 4), + // The data to send can't be larger than buffer_output_size. + 'buffer_output_size' => 10 * 1024 * 1024 ], ], + /* + |-------------------------------------------------------------------------- + | Enable to turn on websocket server. + |-------------------------------------------------------------------------- + */ 'websocket' => [ 'enabled' => env('SWOOLE_HTTP_WEBSOCKET', false), ], + /* + |-------------------------------------------------------------------------- + | Laravel app will be cloned on every requset. + |-------------------------------------------------------------------------- + */ + 'sandbox_mode' => env('SWOOLE_SANDBOX_MODE', false), + + /* + |-------------------------------------------------------------------------- + | Console output will be transfered to response content if enabled. + |-------------------------------------------------------------------------- + */ + 'ob_output' => env('SWOOLE_OB_OUTPUT', true), + /* |-------------------------------------------------------------------------- | Providers here will be registered on every request. |-------------------------------------------------------------------------- */ 'providers' => [ - // App\Providers\AuthServiceProvider::class, + // Illuminate\Auth\AuthServiceProvider::class, + ], + + /* + |-------------------------------------------------------------------------- + | Resolved facades here will be cleared on every request. + |-------------------------------------------------------------------------- + */ + 'facades' => [ + 'auth', 'auth.driver', 'auth.password', 'request' + ], + + /* + |-------------------------------------------------------------------------- + | Instances here will be cleared on every request. + |-------------------------------------------------------------------------- + */ + 'instances' => [ + // ], /* diff --git a/config/swoole_websocket.php b/config/swoole_websocket.php index bd04c0a8..904ca9ab 100644 --- a/config/swoole_websocket.php +++ b/config/swoole_websocket.php @@ -7,16 +7,14 @@ | Replace this handler before you start it |-------------------------------------------------------------------------- */ - 'handler' => SwooleTW\Http\Websocket\WebsocketHandler::class, + 'handler' => SwooleTW\Http\Websocket\SocketIO\WebsocketHandler::class, /* |-------------------------------------------------------------------------- - | Websocket handlers mapping for onMessage callback + | Websocket route file path |-------------------------------------------------------------------------- */ - 'handlers' => [ - // 'event_name' => 'App\Handlers\ExampleHandler@function', - ], + 'route_file' => base_path('routes/websocket.php'), /* |-------------------------------------------------------------------------- @@ -27,11 +25,25 @@ /* |-------------------------------------------------------------------------- - | Default message formatter + | Default frame parser | Replace it if you want to customize your websocket payload |-------------------------------------------------------------------------- */ - 'formatter' => SwooleTW\Http\Websocket\Formatters\DefaultFormatter::class, + 'parser' => SwooleTW\Http\Websocket\SocketIO\SocketIOParser::class, + + /* + |-------------------------------------------------------------------------- + | Heartbeat interval (ms) + |-------------------------------------------------------------------------- + */ + 'ping_interval' => 25000, + + /* + |-------------------------------------------------------------------------- + | Heartbeat interval timeout (ms) + |-------------------------------------------------------------------------- + */ + 'ping_timeout' => 60000, /* |-------------------------------------------------------------------------- @@ -40,6 +52,7 @@ */ 'drivers' => [ 'table' => SwooleTW\Http\Websocket\Rooms\TableRoom::class, + 'redis' => SwooleTW\Http\Websocket\Rooms\RedisRoom::class, ], /* @@ -54,6 +67,20 @@ 'room_size' => 2048, 'client_rows' => 8192, 'client_size' => 2048 + ], + + 'redis' => [ + 'server' => [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', 6379), + 'database' => 0, + 'persistent' => true, + ], + 'options' => [ + // + ], + 'prefix' => 'swoole:', ] ], ]; diff --git a/routes/laravel_routes.php b/routes/laravel_routes.php new file mode 100644 index 00000000..9afb5f6e --- /dev/null +++ b/routes/laravel_routes.php @@ -0,0 +1,17 @@ + 'SwooleTW\Http\Controllers'], function () { + Route::get('socket.io', 'SocketIOController@upgrade'); + Route::post('socket.io', 'SocketIOController@reject'); +}); diff --git a/routes/lumen_routes.php b/routes/lumen_routes.php new file mode 100644 index 00000000..273f48f5 --- /dev/null +++ b/routes/lumen_routes.php @@ -0,0 +1,20 @@ +get('socket.io', [ + 'as' => 'io.get', 'uses' => 'SocketIOController@upgrade' +]); + +$app->post('socket.io', [ + 'as' => 'io.post', 'uses' => 'SocketIOController@reject' +]); diff --git a/routes/websocket.php b/routes/websocket.php new file mode 100644 index 00000000..f029c08d --- /dev/null +++ b/routes/websocket.php @@ -0,0 +1,18 @@ +emit('message', $data); +}); + +// Websocket::on('test', 'ExampleController@method'); \ No newline at end of file diff --git a/src/Controllers/SocketIOController.php b/src/Controllers/SocketIOController.php new file mode 100644 index 00000000..2ed6c8d7 --- /dev/null +++ b/src/Controllers/SocketIOController.php @@ -0,0 +1,41 @@ +input('transport'), $this->transports)) { + return response()->json([ + 'code' => 0, + 'message' => 'Transport unknown' + ], 400); + } + + if ($request->has('sid')) { + return '1:6'; + } + + $payload = json_encode([ + 'sid' => base64_encode(uniqid()), + 'upgrades' => ['websocket'], + 'pingInterval' => config('swoole_websocket.ping_interval'), + 'pingTimeout' => config('swoole_websocket.ping_timeout') + ]); + + return '97:0' . $payload . '2:40'; + } + + public function reject(Request $request) + { + return response()->json([ + 'code' => 3, + 'message' => 'Bad request' + ], 400); + } +} diff --git a/src/HttpServiceProvider.php b/src/HttpServiceProvider.php index 79f8f5bc..394b079f 100644 --- a/src/HttpServiceProvider.php +++ b/src/HttpServiceProvider.php @@ -33,6 +33,13 @@ public function register() */ abstract protected function registerManager(); + /** + * Boot routes. + * + * @return void + */ + abstract protected function bootRoutes(); + /** * Boot the service provider. * @@ -42,8 +49,10 @@ public function boot() { $this->publishes([ __DIR__ . '/../config/swoole_http.php' => base_path('config/swoole_http.php'), - __DIR__ . '/../config/swoole_websocket.php' => base_path('config/swoole_websocket.php') - ], 'config'); + __DIR__ . '/../config/swoole_websocket.php' => base_path('config/swoole_websocket.php'), + __DIR__ . '/../routes/websocket.php' => base_path('routes/websocket.php') + ], 'laravel-swoole'); + $this->bootRoutes(); } /** diff --git a/src/LaravelServiceProvider.php b/src/LaravelServiceProvider.php index ed91899a..ac43a419 100644 --- a/src/LaravelServiceProvider.php +++ b/src/LaravelServiceProvider.php @@ -17,4 +17,14 @@ protected function registerManager() return new Manager($app, 'laravel'); }); } + + /** + * Boot routes. + * + * @return void + */ + protected function bootRoutes() + { + require __DIR__.'/../routes/laravel_routes.php'; + } } diff --git a/src/LumenServiceProvider.php b/src/LumenServiceProvider.php index 34c00d0b..77c3149a 100644 --- a/src/LumenServiceProvider.php +++ b/src/LumenServiceProvider.php @@ -17,4 +17,24 @@ protected function registerManager() return new Manager($app, 'lumen'); }); } + + /** + * Boot routes. + * + * @return void + */ + protected function bootRoutes() + { + $app = $this->app; + + if (property_exists($app, 'router')) { + $app->router->group(['namespace' => 'SwooleTW\Http\Controllers'], function ($app) { + require __DIR__ . '/../routes/lumen_routes.php'; + }); + } else { + $app->group(['namespace' => 'App\Http\Controllers'], function ($app) { + require __DIR__ . '/../routes/lumen_routes.php'; + }); + } + } } diff --git a/src/Server/Application.php b/src/Server/Application.php index 59adeb21..91300af9 100644 --- a/src/Server/Application.php +++ b/src/Server/Application.php @@ -2,10 +2,16 @@ namespace SwooleTW\Http\Server; -use Illuminate\Support\ServiceProvider; +use Illuminate\Http\Request; use Illuminate\Container\Container; use Illuminate\Contracts\Http\Kernel; -use Illuminate\Http\Request; +use Illuminate\Support\Facades\Facade; +use Illuminate\Support\ServiceProvider; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; + +use Laravel\Lumen\Application as LumenApplication; class Application { @@ -36,12 +42,37 @@ class Application protected $kernel; /** - * Preserved service providers to be reset. + * Service providers to be reset. * * @var array */ protected $providers = []; + /** + * Instance names to be reset. + * + * @var array + */ + protected $instances = []; + + /** + * Resolved facades to be reset. + * + * @var array + */ + protected $facades = []; + + /** + * Aliases for pre-resolving. + * + * @var array + */ + protected $resolves = [ + 'view', 'files', 'session', 'session.store', 'routes', + 'db', 'db.factory', 'cache', 'cache.store', 'config', + 'encrypter', 'hash', 'router', 'translator', 'url', 'log' + ]; + /** * Make an application. * @@ -67,6 +98,8 @@ public function __construct($framework, $basePath = null) $this->bootstrap(); $this->initProviders(); + $this->initFacades(); + $this->initInstances(); } /** @@ -74,10 +107,16 @@ public function __construct($framework, $basePath = null) */ protected function bootstrap() { - if ($this->framework == 'laravel') { + $application = $this->getApplication(); + + if ($this->framework === 'laravel') { $bootstrappers = $this->getBootstrappers(); - $this->getApplication()->bootstrapWith($bootstrappers); + $application->bootstrapWith($bootstrappers); + } elseif (is_null(Facade::getFacadeApplication())) { + $application->withFacades(); } + + $this->preResolveInstances($application); } /** @@ -86,7 +125,7 @@ protected function bootstrap() protected function initProviders() { $app = $this->getApplication(); - $providers = $app['config']->get('swoole_http.providers'); + $providers = $app['config']->get('swoole_http.providers', []); foreach ($providers as $provider) { if (! $provider instanceof ServiceProvider) { @@ -96,12 +135,38 @@ protected function initProviders() } } + /** + * Initialize customized instances. + */ + protected function initInstances() + { + $app = $this->getApplication(); + $instances = $app['config']->get('swoole_http.instances', []); + + $this->instances = array_filter($instances, function ($value) { + return is_string($value); + }); + } + + /** + * Initialize customized facades. + */ + protected function initFacades() + { + $app = $this->getApplication(); + $facades = $app['config']->get('swoole_http.facades', []); + + $this->facades = array_filter($facades, function ($value) { + return is_string($value); + }); + } + /** * Re-register and reboot service providers. */ public function resetProviders() { - foreach ($this->providers as $key => $provider) { + foreach ($this->providers as $provider) { if (method_exists($provider, 'register')) { $provider->register(); } @@ -112,6 +177,26 @@ public function resetProviders() } } + /** + * Clear resolved facades. + */ + public function clearFacades() + { + foreach ($this->facades as $facade) { + Facade::clearResolvedInstance($facade); + } + } + + /** + * Clear resolved instances. + */ + public function clearInstances() + { + foreach ($this->instances as $instance) { + $this->getApplication()->forgetInstance($instance); + } + } + /** * Load application. * @@ -146,6 +231,14 @@ public function getKernel() return $this->kernel; } + /** + * Get application framework. + */ + public function getFramework() + { + return $this->framework; + } + /** * Run framework. * @@ -154,11 +247,34 @@ public function getKernel() */ public function run(Request $request) { + ob_start(); + + // handle request with laravel or lumen $method = sprintf('run%s', ucfirst($this->framework)); $response = $this->$method($request); + // prepare content for ob + $content = ''; + $shouldUseOb = $this->application['config']->get('swoole_http.ob_output', true); + if ($response instanceof StreamedResponse || + $response instanceof BinaryFileResponse) { + $shouldUseOb = false; + } elseif ($response instanceof SymfonyResponse) { + $content = $response->getContent(); + } else { + $content = (string) $response; + } + + // process terminating logics $this->terminate($request, $response); + // set ob content to response + if ($shouldUseOb && strlen($content) === 0 && ob_get_length() > 0) { + $response->setContent(ob_get_contents()); + } + + ob_end_clean(); + return $response; } @@ -193,7 +309,6 @@ protected function getBootstrappers() { $kernel = $this->getKernel(); - // Reflect Kernel $reflection = new \ReflectionObject($kernel); $bootstrappersMethod = $reflection->getMethod('bootstrappers'); @@ -253,6 +368,24 @@ public function terminate(Request $request, $response) protected function terminateLaravel(Request $request, $response) { $this->getKernel()->terminate($request, $response); + + // clean laravel session + if ($request->hasSession()) { + $session = $request->getSession(); + if (method_exists($session, 'clear')) { + $session->clear(); + } elseif (method_exists($session, 'flush')) { + $session->flush(); + } + } + + // clean laravel cookie queue + if (isset($this->application['cookie'])) { + $cookies = $this->application['cookie']; + foreach ($cookies->getQueuedCookies() as $name => $cookie) { + $cookies->unqueue($name); + } + } } /** @@ -265,7 +398,6 @@ protected function terminateLumen(Request $request, $response) { $application = $this->getApplication(); - // Reflections $reflection = new \ReflectionObject($application); $middleware = $reflection->getProperty('middleware'); @@ -278,4 +410,30 @@ protected function terminateLumen(Request $request, $response) $callTerminableMiddleware->invoke($application, $response); } } + + /** + * Reslove some instances before request. + */ + protected function preResolveInstances($application) + { + foreach ($this->resolves as $abstract) { + if ($application->offsetExists($abstract)) { + $application->make($abstract); + } + } + } + + /** + * Clone laravel app and kernel while being cloned. + */ + public function __clone() + { + $application = clone $this->application; + + $this->application = $application; + + if ($this->framework === 'laravel') { + $this->kernel->setApplication($application); + } + } } diff --git a/src/Server/Manager.php b/src/Server/Manager.php index 89ef05be..29a80bc6 100644 --- a/src/Server/Manager.php +++ b/src/Server/Manager.php @@ -3,18 +3,20 @@ namespace SwooleTW\Http\Server; use Exception; -use Swoole\Table as SwooleTable; +use SwooleTW\Http\Server\Sandbox; use Swoole\Http\Server as HttpServer; use Illuminate\Support\Facades\Facade; -use Illuminate\Contracts\Container\Container; -use Swoole\WebSocket\Server as WebSocketServer; use SwooleTW\Http\Websocket\Websocket; +use SwooleTW\Http\Table\CanSwooleTable; use SwooleTW\Http\Websocket\CanWebsocket; +use Illuminate\Contracts\Container\Container; use SwooleTW\Http\Websocket\Rooms\RoomContract; +use Swoole\WebSocket\Server as WebSocketServer; +use Illuminate\Contracts\Debug\ExceptionHandler; class Manager { - use CanWebsocket; + use CanWebsocket, CanSwooleTable; const MAC_OSX = 'Darwin'; @@ -42,11 +44,6 @@ class Manager */ protected $app; - /** - * @var \SwooleTW\Http\Server\Table - */ - protected $table; - /** * @var string */ @@ -57,6 +54,16 @@ class Manager */ protected $basePath; + /** + * @var boolean + */ + protected $isSandbox; + + /** + * @var \SwooleTW\Http\Server\Sandbox + */ + protected $sandbox; + /** * Server events. * @@ -107,6 +114,7 @@ protected function initialize() { $this->setProcessName('manager process'); + $this->setIsSandbox(); $this->createTables(); $this->prepareWebsocket(); $this->createSwooleServer(); @@ -114,28 +122,18 @@ protected function initialize() $this->setSwooleServerListeners(); } - /** - * Prepare settings if websocket is enabled. - */ - protected function createTables() - { - $this->table = new Table; - $this->registerTables(); - } - /** * Prepare settings if websocket is enabled. */ protected function prepareWebsocket() { $isWebsocket = $this->container['config']->get('swoole_http.websocket.enabled'); - $formatter = $this->container['config']->get('swoole_websocket.formatter'); + $parser = $this->container['config']->get('swoole_websocket.parser'); if ($isWebsocket) { array_push($this->events, ...$this->wsEvents); $this->isWebsocket = true; - $this->setFormatter(new $formatter); - $this->setWebsocketHandler(); + $this->setParser(new $parser); $this->setWebsocketRoom(); } } @@ -196,24 +194,37 @@ public function onStart() /** * "onWorkerStart" listener. */ - public function onWorkerStart() + public function onWorkerStart(HttpServer $server) { $this->clearCache(); $this->setProcessName('worker process'); $this->container['events']->fire('swoole.workerStart', func_get_args()); + // don't init laravel app in task workers + if ($server->taskworker) { + return; + } + // clear events instance in case of repeated listeners in worker process Facade::clearResolvedInstance('events'); + // initialize laravel app $this->createApplication(); $this->setLaravelApp(); - $this->bindSwooleServer(); - $this->bindSwooleTable(); + // bind after setting laravel app + $this->bindToLaravelApp(); + + // set application to sandbox environment + if ($this->isSandbox) { + $this->sandbox = Sandbox::make($this->getApplication()); + } + + // load websocket handlers after binding websocket to laravel app if ($this->isWebsocket) { - $this->bindRoom(); - $this->bindWebsocket(); + $this->setWebsocketHandler(); + $this->loadWebsocketRoutes(); } } @@ -225,30 +236,69 @@ public function onWorkerStart() */ public function onRequest($swooleRequest, $swooleResponse) { - $this->container['events']->fire('swoole.request'); + $this->app['events']->fire('swoole.request'); - // Reset user-customized providers - $this->getApplication()->resetProviders(); - $illuminateRequest = Request::make($swooleRequest)->toIlluminate(); + $this->resetOnRequest(); + + $application = $this->getApplication(); try { - $illuminateResponse = $this->getApplication()->run($illuminateRequest); + // transform swoole request to illuminate request + $illuminateRequest = Request::make($swooleRequest)->toIlluminate(); + + // use cloned application if sandbox mode is on + if ($this->isSandbox) { + $application->getApplication()->instance('request', $illuminateRequest); + $application = $this->sandbox->getApplication(); + $this->sandbox->enable(); + } + + // bind illuminate request to laravel/lumen + $application->getApplication()->instance('request', $illuminateRequest); + Facade::clearResolvedInstance('request'); + + // handle request via laravel/lumen's dispatcher + $illuminateResponse = $application->run($illuminateRequest); $response = Response::make($illuminateResponse, $swooleResponse); $response->send(); - } catch (Exception $e) { - $this->logServerError($e); + // disable and recycle sandbox resource + if ($this->isSandbox) { + $this->sandbox->disable(); + } + } catch (Exception $e) { try { - $swooleResponse->status(500); - $swooleResponse->end('Oops! An unexpected error occurred.'); + $exceptionResponse = $this->app[ExceptionHandler::class]->render($illuminateRequest, $e); + $response = Response::make($exceptionResponse, $swooleResponse); + $response->send(); } catch (Exception $e) { - // Catch: zm_deactivate_swoole: Fatal error: Uncaught exception - // 'ErrorException' with message 'swoole_http_response::status(): - // http client#2 is not exist. + $this->logServerError($e); } } } + /** + * Reset on every request. + */ + protected function resetOnRequest() + { + // Reset websocket data + if ($this->isWebsocket) { + $this->websocket->reset(true); + } + + if ($this->isSandbox) { + return; + } + + // Reset user-customized providers + $this->getApplication()->resetProviders(); + // Clear user-customized facades + $this->getApplication()->clearFacades(); + // Clear user-customized instances + $this->getApplication()->clearInstances(); + } + /** * Set onTask listener. */ @@ -283,7 +333,7 @@ public function onShutdown() { $this->removePidFile(); - $this->container['events']->fire('swoole.shutdown', func_get_args()); + $this->app['events']->fire('swoole.shutdown', func_get_args()); } /** @@ -308,6 +358,28 @@ protected function getApplication() return $this->application; } + /** + * Set bindings to Laravel app. + */ + protected function bindToLaravelApp() + { + $this->bindSwooleServer(); + $this->bindSwooleTable(); + + if ($this->isWebsocket) { + $this->bindRoom(); + $this->bindWebsocket(); + } + } + + /** + * Set isSandbox config. + */ + protected function setIsSandbox() + { + $this->isSandbox = $this->container['config']->get('swoole_http.sandbox_mode', false); + } + /** * Set Laravel app. */ @@ -326,16 +398,6 @@ protected function bindSwooleServer() }); } - /** - * Bind swoole table to Laravel app container. - */ - protected function bindSwooleTable() - { - $this->app->singleton('swoole.table', function () { - return $this->table; - }); - } - /** * Gets pid file path. * @@ -416,27 +478,4 @@ protected function logServerError(Exception $e) fwrite($output, sprintf('%s%s(%d): %s', $prefix, $e->getFile(), $e->getLine(), $e->getMessage()) . PHP_EOL); } - - /** - * Register user-defined swoole tables. - */ - protected function registerTables() - { - $tables = $this->container['config']->get('swoole_http.tables') ?? []; - - foreach ($tables as $key => $value) { - $table = new SwooleTable($value['size']); - $columns = $value['columns'] ?? []; - foreach ($columns as $column) { - if (isset($column['size'])) { - $table->column($column['name'], $column['type'], $column['size']); - } else { - $table->column($column['name'], $column['type']); - } - } - $table->create(); - - $this->table->add($key, $table); - } - } } diff --git a/src/Server/Sandbox.php b/src/Server/Sandbox.php new file mode 100644 index 00000000..d7926a6b --- /dev/null +++ b/src/Server/Sandbox.php @@ -0,0 +1,178 @@ +setApplication($application); + } + + /** + * Set a base application + * + * @param \SwooleTW\Http\Server\Application + */ + public function setApplication(Application $application) + { + $this->application = $application; + } + + /** + * Get an application snapshot + * + * @return \SwooleTW\Http\Server\Application + */ + public function getApplication() + { + if ($this->snapshot instanceOf Application) { + return $this->snapshot; + } + + $snapshot = clone $this->application; + $this->resetLaravelApp($snapshot->getApplication()); + $this->rebindRouterContainer($snapshot->getApplication()); + + return $this->snapshot = $snapshot; + } + + /** + * Reset Laravel/Lumen Application. + */ + protected function resetLaravelApp($application) + { + if ($this->isFramework('laravel')) { + $application->bootstrapWith([ + 'Illuminate\Foundation\Bootstrap\LoadConfiguration' + ]); + } elseif ($this->isFramework('lumen')) { + $reflector = new \ReflectionMethod(LumenApplication::class, 'registerConfigBindings'); + $reflector->setAccessible(true); + $reflector->invoke($application); + } + } + + /** + * Rebind laravel's container in router. + */ + protected function rebindRouterContainer($application) + { + if ($this->isFramework('laravel')) { + $router = $application->make('router'); + $closure = function () use ($application) { + $this->container = $application; + }; + + $resetRouter = $closure->bindTo($router, $router); + $resetRouter(); + } elseif ($this->isFramework('lumen')) { + // lumen router only exists after lumen 5.5 + if (property_exists($application, 'router')) { + $application->router->app = $application; + } + } + } + + /** + * Get application's framework. + */ + protected function isFramework(string $name) + { + return $this->application->getFramework() === $name; + } + + /** + * Get a laravel snapshot + * + * @return \Illuminate\Container\Container + */ + public function getLaravelApp() + { + if ($this->snapshot instanceOf Application) { + return $this->snapshot->getApplication(); + } + + return $this->getApplication()->getApplication(); + } + + /** + * Set laravel snapshot to container and facade. + */ + public function enable() + { + if (is_null($this->snapshot)) { + $this->getApplication($this->application); + } + + $this->setInstance($this->getLaravelApp()); + $this->enabled = true; + } + + /** + * Set original laravel app to container and facade. + */ + public function disable() + { + if (! $this->enabled) { + return; + } + + if ($this->snapshot instanceOf Application) { + $this->snapshot = null; + } + + $this->setInstance($this->application->getApplication()); + } + + /** + * Replace app's self bindings. + */ + protected function setInstance($application) + { + $application->instance('app', $application); + $application->instance(Container::class, $application); + + if ($this->isFramework('lumen')) { + $application->instance(LumenApplication::class, $application); + } + + Container::setInstance($application); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($application); + } +} diff --git a/src/Server/Table.php b/src/Server/Table.php deleted file mode 100644 index 71ea0db0..00000000 --- a/src/Server/Table.php +++ /dev/null @@ -1,27 +0,0 @@ -tables[$name] = $table; - - return $this; - } - - public function get(string $name) - { - return $this->tables[$name] ?? null; - } - - public function getAll() - { - return $this->tables; - } -} diff --git a/src/Table/CanSwooleTable.php b/src/Table/CanSwooleTable.php new file mode 100644 index 00000000..25faac32 --- /dev/null +++ b/src/Table/CanSwooleTable.php @@ -0,0 +1,56 @@ +table = new SwooleTable; + $this->registerTables(); + } + + /** + * Register user-defined swoole tables. + */ + protected function registerTables() + { + $tables = $this->container['config']->get('swoole_http.tables', []); + + foreach ($tables as $key => $value) { + $table = new Table($value['size']); + $columns = $value['columns'] ?? []; + foreach ($columns as $column) { + if (isset($column['size'])) { + $table->column($column['name'], $column['type'], $column['size']); + } else { + $table->column($column['name'], $column['type']); + } + } + $table->create(); + + $this->table->add($key, $table); + } + } + + /** + * Bind swoole table to Laravel app container. + */ + protected function bindSwooleTable() + { + $this->app->singleton('swoole.table', function () { + return $this->table; + }); + } +} diff --git a/src/Server/Facades/Table.php b/src/Table/Facades/SwooleTable.php similarity index 76% rename from src/Server/Facades/Table.php rename to src/Table/Facades/SwooleTable.php index a9bc6028..f5441690 100644 --- a/src/Server/Facades/Table.php +++ b/src/Table/Facades/SwooleTable.php @@ -1,10 +1,10 @@ tables[$name] = $table; + + return $this; + } + + /** + * Get a swoole table by its name from existing tables. + * + * @param string $name + * @return \Swoole\Table $table + */ + public function get(string $name) + { + return $this->tables[$name] ?? null; + } + + /** + * Get all existing swoole tables. + * + * @return array + */ + public function getAll() + { + return $this->tables; + } + + /** + * Dynamically access table. + * + * @param string $key + * @return table + */ + public function __get($key) + { + return $this->get($key); + } +} diff --git a/src/Websocket/CanWebsocket.php b/src/Websocket/CanWebsocket.php index a6308cac..4324df6c 100644 --- a/src/Websocket/CanWebsocket.php +++ b/src/Websocket/CanWebsocket.php @@ -6,10 +6,10 @@ use Swoole\Websocket\Frame; use Swoole\Websocket\Server; use SwooleTW\Http\Server\Request; +use SwooleTW\Http\Websocket\Parser; use SwooleTW\Http\Websocket\Websocket; use SwooleTW\Http\Websocket\HandlerContract; use SwooleTW\Http\Websocket\Rooms\RoomContract; -use SwooleTW\Http\Websocket\Formatters\FormatterContract; trait CanWebsocket { @@ -23,15 +23,20 @@ trait CanWebsocket */ protected $websocketHandler; + /** + * @var SwooleTW\Http\Websocket\Websocket + */ + protected $websocket; + /** * @var SwooleTW\Http\Websocket\Rooms\RoomContract */ protected $websocketRoom; /** - * @var SwooleTW\Http\Websocket\Formatters\FormatterContract + * @var SwooleTW\Http\Websocket\Parser */ - protected $formatter; + protected $parser; /** * Websocket server events. @@ -51,7 +56,14 @@ public function onOpen(Server $server, $swooleRequest) $illuminateRequest = Request::make($swooleRequest)->toIlluminate(); try { - $this->websocketHandler->onOpen($swooleRequest->fd, $illuminateRequest); + // check if socket.io connection established + if ($this->websocketHandler->onOpen($swooleRequest->fd, $illuminateRequest)) { + $this->websocket->reset(true)->setSender($swooleRequest->fd); + // trigger 'connect' websocket event + if ($this->websocket->eventExists('connect')) { + $this->websocket->call('connect', $illuminateRequest); + } + } } catch (Exception $e) { $this->logServerError($e); } @@ -65,14 +77,21 @@ public function onOpen(Server $server, $swooleRequest) */ public function onMessage(Server $server, Frame $frame) { - $this->app['swoole.websocket']->setSender($frame->fd); - - $payload = $this->formatter->input($frame); - $handler = $this->container['config']->get("swoole_websocket.handlers.{$payload['event']}"); + $data = $frame->data; try { - if ($handler) { - $this->app->call($handler, [$frame->fd, $payload['data']]); + $skip = $this->parser->execute($server, $frame); + + if ($skip) { + return; + } + + $payload = $this->parser->decode($frame); + + $this->websocket->reset(true)->setSender($frame->fd); + + if ($this->websocket->eventExists($payload['event'])) { + $this->websocket->call($payload['event'], $payload['data']); } else { $this->websocketHandler->onMessage($frame); } @@ -95,12 +114,17 @@ public function onClose(Server $server, $fd, $reactorId) } try { - $this->websocketHandler->onClose($fd, $reactorId); + // leave all rooms + $this->websocket->reset(true)->setSender($fd)->leaveAll(); + // trigger 'disconnect' websocket event + if ($this->websocket->eventExists('disconnect')) { + $this->websocket->call('disconnect'); + } else { + $this->websocketHandler->onClose($fd, $reactorId); + } } catch (Exception $e) { $this->logServerError($e); } - - $this->app['swoole.websocket']->setSender($fd)->leaveAll(); } /** @@ -111,12 +135,8 @@ public function onClose(Server $server, $fd, $reactorId) */ public function pushMessage(Server $server, array $data) { - $opcode = $data['opcode'] ?? 1; - $sender = $data['sender'] ?? 0; - $fds = $data['fds'] ?? []; - $broadcast = $data['broadcast'] ?? false; - $event = $data['event'] ?? null; - $message = $this->formatter->output($event, $data['message']); + [$opcode, $sender, $fds, $broadcast, $assigned, $event, $message] = $this->normalizePushData($data); + $message = $this->parser->encode($event, $message); // attach sender if not broadcast if (! $broadcast && $sender && ! in_array($sender, $fds)) { @@ -124,7 +144,7 @@ public function pushMessage(Server $server, array $data) } // check if to broadcast all clients - if ($broadcast && empty($fds)) { + if ($broadcast && empty($fds) && ! $assigned) { foreach ($server->connections as $fd) { if ($this->isWebsocket($fd)) { $fds[] = $fd; @@ -142,23 +162,23 @@ public function pushMessage(Server $server, array $data) } /** - * Set message formatter for websocket. + * Set frame parser for websocket. * - * @param \SwooleTW\Http\Websocket\Formatter\FormatterContract $formatter + * @param \SwooleTW\Http\Websocket\Parser $parser */ - public function setFormatter(FormatterContract $formatter) + public function setParser(Parser $parser) { - $this->formatter = $formatter; + $this->parser = $parser; return $this; } /** - * Get message formatter for websocket. + * Get frame parser for websocket. */ - public function getFormatter() + public function getParser() { - return $this->formatter; + return $this->parser; } /** @@ -171,19 +191,6 @@ protected function isWebsocket(int $fd) return array_key_exists('websocket_status', $info) && $info['websocket_status']; } - /** - * Set websocket handler for onOpen and onClose callback. - */ - protected function setWebsocketRoom() - { - $driver = $this->container['config']->get('swoole_websocket.default'); - $configs = $this->container['config']->get("swoole_websocket.settings.{$driver}"); - $className = $this->container['config']->get("swoole_websocket.drivers.{$driver}"); - - $this->websocketRoom = new $className($configs); - $this->websocketRoom->prepare(); - } - /** * Set websocket handler for onOpen and onClose callback. */ @@ -195,7 +202,7 @@ protected function setWebsocketHandler() throw new Exception('websocket handler not set in swoole_websocket config'); } - $handler = $this->container->make($handlerClass); + $handler = $this->app->make($handlerClass); if (! $handler instanceof HandlerContract) { throw new Exception(sprintf('%s must implement %s', get_class($handler), HandlerContract::class)); @@ -204,6 +211,19 @@ protected function setWebsocketHandler() $this->websocketHandler = $handler; } + /** + * Set websocket handler for onOpen and onClose callback. + */ + protected function setWebsocketRoom() + { + $driver = $this->container['config']->get('swoole_websocket.default'); + $configs = $this->container['config']->get("swoole_websocket.settings.{$driver}"); + $className = $this->container['config']->get("swoole_websocket.drivers.{$driver}"); + + $this->websocketRoom = new $className($configs); + $this->websocketRoom->prepare(); + } + /** * Bind room instance to Laravel app container. */ @@ -221,8 +241,38 @@ protected function bindRoom() protected function bindWebsocket() { $this->app->singleton(Websocket::class, function ($app) { - return new Websocket($this->websocketRoom); + return $this->websocket = new Websocket($app['swoole.room']); }); $this->app->alias(Websocket::class, 'swoole.websocket'); } + + /** + * Load websocket routes file. + */ + protected function loadWebsocketRoutes() + { + $routePath = $this->container['config']->get('swoole_websocket.route_file'); + + if (! file_exists($routePath)) { + $routePath = __DIR__ . '/../../routes/websocket.php'; + } + + return require $routePath; + } + + /** + * Normalize data for message push. + */ + protected function normalizePushData(array $data) + { + $opcode = $data['opcode'] ?? 1; + $sender = $data['sender'] ?? 0; + $fds = $data['fds'] ?? []; + $broadcast = $data['broadcast'] ?? false; + $assigned = $data['assigned'] ?? false; + $event = $data['event'] ?? null; + $message = $data['message'] ?? null; + + return [$opcode, $sender, $fds, $broadcast, $assigned, $event, $message]; + } } diff --git a/src/Websocket/Formatters/DefaultFormatter.php b/src/Websocket/Formatters/DefaultFormatter.php deleted file mode 100644 index cf621b36..00000000 --- a/src/Websocket/Formatters/DefaultFormatter.php +++ /dev/null @@ -1,38 +0,0 @@ -data, true); - - return [ - 'event' => $data['event'] ?? null, - 'data' => $data['data'] ?? null - ]; - } - - /** - * Output message for websocket push. - * - * @return mixed - */ - public function output($event, $data) - { - return json_encode([ - 'event' => $event, - 'data' => $data - ]); - } -} diff --git a/src/Websocket/Formatters/FormatterContract.php b/src/Websocket/Formatters/FormatterContract.php deleted file mode 100644 index 462e4d31..00000000 --- a/src/Websocket/Formatters/FormatterContract.php +++ /dev/null @@ -1,21 +0,0 @@ -strategies as $strategy) { + $result = App::call($strategy . '@handle', [ + 'server' => $server, + 'frame' => $frame + ]); + if ($result === true) { + $skip = true; + break; + } + } + + return $skip; + } + + /** + * Encode output payload for websocket push. + * + * @return mixed + */ + public function encode($event, $data) + { + return json_encode([ + 'event' => $event, + 'data' => $data + ]); + } + + /** + * Input message on websocket connected. + * Define and return event name and payload data here. + * + * @param \Swoole\Websocket\Frame $frame + * @return array + */ + public function decode(Frame $frame) + { + $data = json_decode($frame->data, true); + + return [ + 'event' => $data['event'] ?? null, + 'data' => $data['data'] ?? null + ]; + } +} diff --git a/src/Websocket/Rooms/RedisRoom.php b/src/Websocket/Rooms/RedisRoom.php new file mode 100644 index 00000000..db3d2d21 --- /dev/null +++ b/src/Websocket/Rooms/RedisRoom.php @@ -0,0 +1,159 @@ +config = $config; + } + + public function prepare(RedisClient $redis = null) + { + $this->setRedis($redis); + $this->setPrefix(); + $this->cleanRooms(); + } + + /** + * Set redis client. + */ + public function setRedis(RedisClient $redis = null) + { + $server = $this->config['server'] ?? []; + $options = $this->config['options'] ?? []; + + // forbid setting prefix from options + if (array_key_exists('prefix', $options)) { + unset($options['prefix']); + } + + if ($redis) { + $this->redis = $redis; + } else { + $this->redis = new RedisClient($server, $options); + } + } + + /** + * Set key prefix from config. + */ + protected function setPrefix() + { + if (array_key_exists('prefix', $this->config)) { + $this->prefix = $this->config['prefix']; + } + } + + /** + * Get redis client. + */ + public function getRedis() + { + return $this->redis; + } + + public function add(int $fd, string $room) + { + $this->addAll($fd, [$room]); + } + + public function addAll(int $fd, array $roomNames) + { + $this->addValue($fd, $roomNames, 'sids'); + + foreach ($roomNames as $room) { + $this->addValue($room, [$fd], 'rooms'); + } + } + + public function delete(int $fd, string $room) + { + $this->deleteAll($fd, [$room]); + } + + public function deleteAll(int $fd, array $roomNames = []) + { + $roomNames = count($roomNames) ? $roomNames : $this->getRooms($fd); + $this->removeValue($fd, $roomNames, 'sids'); + + foreach ($roomNames as $room) { + $this->removeValue($room, [$fd], 'rooms'); + } + } + + public function addValue($key, array $values, string $table) + { + $this->checkTable($table); + $redisKey = $this->getKey($key, $table); + + $this->redis->pipeline(function ($pipe) use ($redisKey, $values) { + foreach ($values as $value) { + $pipe->sadd($redisKey, $value); + } + }); + + return $this; + } + + public function removeValue($key, array $values, string $table) + { + $this->checkTable($table); + $redisKey = $this->getKey($key, $table); + + $this->redis->pipeline(function ($pipe) use ($redisKey, $values) { + foreach ($values as $value) { + $pipe->srem($redisKey, $value); + } + }); + + return $this; + } + + public function getClients(string $room) + { + return $this->getValue($room, 'rooms'); + } + + public function getRooms(int $fd) + { + return $this->getValue($fd, 'sids'); + } + + protected function checkTable(string $table) + { + if (! in_array($table, ['rooms', 'sids'])) { + throw new \InvalidArgumentException('invalid table name.'); + } + } + + public function getValue(string $key, string $table) + { + $this->checkTable($table); + + return $this->redis->smembers($this->getKey($key, $table)); + } + + public function getKey(string $key, string $table) + { + return "{$this->prefix}{$table}:{$key}"; + } + + protected function cleanRooms() + { + $keys = $this->redis->keys("{$this->prefix}*"); + if (count($keys)) { + $this->redis->del($keys); + } + } +} diff --git a/src/Websocket/SocketIO/Packet.php b/src/Websocket/SocketIO/Packet.php new file mode 100644 index 00000000..07a4188a --- /dev/null +++ b/src/Websocket/SocketIO/Packet.php @@ -0,0 +1,155 @@ + 'OPEN', + 1 => 'CLOSE', + 2 => 'PING', + 3 => 'PONG', + 4 => 'MESSAGE', + 5 => 'UPGRADE', + 6 => 'NOOP' + ]; + + /** + * Engine.io packet types. + */ + public static $engineTypes = [ + 0 => 'CONNECT', + 1 => 'DISCONNECT', + 2 => 'EVENT', + 3 => 'ACK', + 4 => 'ERROR', + 5 => 'BINARY_EVENT', + 6 => 'BINARY_ACK' + ]; + + /** + * Get socket packet type of a raw payload. + */ + public static function getSocketType(string $packet) + { + $type = $packet[0] ?? null; + + if (! array_key_exists($type, static::$socketTypes)) { + return null; + } + + return (int) $type; + } + + /** + * Get data packet from a raw payload. + */ + public static function getPayload(string $packet) + { + $packet = trim($packet); + $start = strpos($packet, '['); + + if ($start === false || substr($packet, -1) !== ']') { + return null; + } + + $data = substr($packet, $start, strlen($packet) - $start); + $data = json_decode($data, true); + + if (is_null($data)) { + return null; + } + + return [ + 'event' => $data[0], + 'data' => $data[1] ?? null + ]; + } + + /** + * Return if a socket packet belongs to specific type. + */ + public static function isSocketType($packet, string $typeName) + { + $type = array_search(strtoupper($typeName), static::$socketTypes); + + if ($type === false) { + return false; + } + + return static::getSocketType($packet) === $type; + } +} \ No newline at end of file diff --git a/src/Websocket/SocketIO/SocketIOParser.php b/src/Websocket/SocketIO/SocketIOParser.php new file mode 100644 index 00000000..2a7d89d1 --- /dev/null +++ b/src/Websocket/SocketIO/SocketIOParser.php @@ -0,0 +1,50 @@ +data); + + return [ + 'event' => $payload['event'] ?? null, + 'data' => $payload['data'] ?? null + ]; + } +} diff --git a/src/Websocket/SocketIO/Strategies/HeartbeatStrategy.php b/src/Websocket/SocketIO/Strategies/HeartbeatStrategy.php new file mode 100644 index 00000000..0150f157 --- /dev/null +++ b/src/Websocket/SocketIO/Strategies/HeartbeatStrategy.php @@ -0,0 +1,40 @@ +data; + $packetLength = strlen($packet); + $payload = ''; + + if (Packet::getPayload($packet)) { + return false; + } + + if ($isPing = Packet::isSocketType($packet, 'ping')) { + $payload .= Packet::PONG; + } + + if ($isPing && $packetLength > 1) { + $payload .= substr($packet, 1, $packetLength - 1); + } + + if ($isPing) { + $server->push($frame->fd, $payload); + } + + return true; + } +} diff --git a/src/Websocket/SocketIO/WebsocketHandler.php b/src/Websocket/SocketIO/WebsocketHandler.php new file mode 100644 index 00000000..80ba4848 --- /dev/null +++ b/src/Websocket/SocketIO/WebsocketHandler.php @@ -0,0 +1,61 @@ +input('sid')) { + $payload = json_encode([ + 'sid' => base64_encode(uniqid()), + 'upgrades' => [], + 'pingInterval' => Config::get('swoole_websocket.ping_interval'), + 'pingTimeout' => Config::get('swoole_websocket.ping_timeout') + ]); + $initPayload = Packet::OPEN . $payload; + $connectPayload = Packet::MESSAGE . Packet::CONNECT; + + app('swoole.server')->push($fd, $initPayload); + app('swoole.server')->push($fd, $connectPayload); + + return true; + } + + return false; + } + + /** + * "onMessage" listener. + * only triggered when event handler not found + * + * @param \Swoole\Websocket\Frame $frame + */ + public function onMessage(Frame $frame) + { + // + } + + /** + * "onClose" listener. + * + * @param int $fd + * @param int $reactorId + */ + public function onClose($fd, $reactorId) + { + // + } +} diff --git a/src/Websocket/Websocket.php b/src/Websocket/Websocket.php index f740ffed..45ad2e7a 100644 --- a/src/Websocket/Websocket.php +++ b/src/Websocket/Websocket.php @@ -2,20 +2,44 @@ namespace SwooleTW\Http\Websocket; +use InvalidArgumentException; +use Illuminate\Support\Facades\App; use SwooleTW\Http\Websocket\Rooms\RoomContract; class Websocket { const PUSH_ACTION = 'push'; + /** + * Determine if to broadcast. + * + * @var boolean + */ protected $isBroadcast = false; + /** + * Scoket sender's fd. + * + * @var integer + */ protected $sender; + /** + * Recepient's fd or room name. + * + * @var array + */ protected $to = []; /** - * Room. + * Websocket event callbacks. + * + * @var array + */ + protected $callbacks = []; + + /** + * Room adapter. * * @var SwooleTW\Http\Websocket\Rooms\RoomContract */ @@ -28,6 +52,9 @@ public function __construct(RoomContract $room) $this->room = $room; } + /** + * Set broadcast to true. + */ public function broadcast() { $this->isBroadcast = true; @@ -36,7 +63,9 @@ public function broadcast() } /** - * @param integer, string (fd or room) + * Set a recepient's fd or a room name. + * + * @param integer, string */ public function to($value) { @@ -46,6 +75,8 @@ public function to($value) } /** + * Set multiple recepients' fdd or room names. + * * @param array (fds or rooms) */ public function toAll(array $values) @@ -60,6 +91,8 @@ public function toAll(array $values) } /** + * Join sender to a room. + * * @param string */ public function join(string $room) @@ -70,6 +103,8 @@ public function join(string $room) } /** + * Join sender to multiple rooms. + * * @param array */ public function joinAll(array $rooms) @@ -80,6 +115,8 @@ public function joinAll(array $rooms) } /** + * Make sender leave a room. + * * @param string */ public function leave(string $room) @@ -90,6 +127,8 @@ public function leave(string $room) } /** + * Make sender leave multiple rooms. + * * @param array */ public function leaveAll(array $rooms = []) @@ -99,22 +138,46 @@ public function leaveAll(array $rooms = []) return $this; } + /** + * Emit data and reset some status. + * + * @param string + * @param mixed + */ public function emit(string $event, $data) { - app('swoole.server')->task([ + $fds = $this->getFds(); + $assigned = ! empty($this->to); + + // if no fds are found, but rooms are assigned + // that means trying to emit to a non-existing room + // skip it directly instead of pushing to a task queue + if (empty($fds) && $assigned) { + return false; + } + + $result = app('swoole.server')->task([ 'action' => static::PUSH_ACTION, 'data' => [ 'sender' => $this->sender, - 'fds' => $this->getFds(), + 'fds' => $fds, 'broadcast' => $this->isBroadcast, + 'assigned' => $assigned, 'event' => $event, 'message' => $data ] ]); $this->reset(); + + return $result === false ? false : true; } + /** + * An alias of `join` function. + * + * @param string + */ public function in(string $room) { $this->join($room); @@ -122,6 +185,58 @@ public function in(string $room) return $this; } + /** + * Register an event name with a closure binding. + * + * @param string + * @param callback + */ + public function on(string $event, $callback) + { + if (! is_string($callback) && ! is_callable($callback)) { + throw new InvalidArgumentException( + 'Invalid websocket callback. Must be a string or callable.' + ); + } + + $this->callbacks[$event] = $callback; + + return $this; + } + + /** + * Check if this event name exists. + * + * @param string + */ + public function eventExists(string $event) + { + return array_key_exists($event, $this->callbacks); + } + + /** + * Execute callback function by its event name. + * + * @param string + * @param mixed + */ + public function call(string $event, $data = null) + { + if (! $this->eventExists($event)) { + return null; + } + + return App::call($this->callbacks[$event], [ + 'websocket' => $this, + 'data' => $data + ]); + } + + /** + * Set sender fd. + * + * @param integer + */ public function setSender(int $fd) { $this->sender = $fd; @@ -129,21 +244,33 @@ public function setSender(int $fd) return $this; } + /** + * Get current sender fd. + */ public function getSender() { return $this->sender; } + /** + * Get broadcast status value. + */ public function getIsBroadcast() { return $this->isBroadcast; } + /** + * Get push destinations (fd or room name). + */ public function getTo() { return $this->to; } + /** + * Get all fds we're going to push data to. + */ protected function getFds() { $fds = array_filter($this->to, function ($value) { @@ -152,16 +279,24 @@ protected function getFds() $rooms = array_diff($this->to, $fds); foreach ($rooms as $room) { - $fds = array_unique(array_merge($fds, $this->room->getClients($room))); + $fds = array_merge($fds, $this->room->getClients($room)); } - return array_values($fds); + return array_values(array_unique($fds)); } - protected function reset() + /** + * Reset some data status. + */ + public function reset($force = false) { $this->isBroadcast = false; - $this->sender = null; $this->to = []; + + if ($force) { + $this->sender = null; + } + + return $this; } } diff --git a/src/Websocket/WebsocketHandler.php b/src/Websocket/WebsocketHandler.php deleted file mode 100644 index 5db9551c..00000000 --- a/src/Websocket/WebsocketHandler.php +++ /dev/null @@ -1,43 +0,0 @@ -fire('swoole.onOpen', compact('fd', 'request')); - } - - /** - * "onMessage" listener. - * only triggered when event handler not found - * - * @param \Swoole\Websocket\Frame $frame - */ - public function onMessage(Frame $frame) - { - app('events')->fire('swoole.onMessage', compact('frame')); - } - - /** - * "onClose" listener. - * - * @param int $fd - * @param int $reactorId - */ - public function onClose($fd, $reactorId) - { - app('events')->fire('swoole.onClose', compact('fd', 'reactorId')); - } -} diff --git a/tests/Server/SandboxTest.php b/tests/Server/SandboxTest.php new file mode 100644 index 00000000..c248393d --- /dev/null +++ b/tests/Server/SandboxTest.php @@ -0,0 +1,80 @@ +assertTrue($this->getSandbox() instanceof Sandbox); + } + + public function testMakeSandbox() + { + $application = m::mock(Application::class); + $sandbox = Sandbox::make($application); + + $this->assertTrue($sandbox instanceof Sandbox); + } + + public function testGetApplication() + { + $this->assertTrue($this->getSandbox()->getApplication() instanceof Application); + } + + protected function getSandbox() + { + $container = $this->getContainer(); + $application = $this->getApplication(); + + $reflector = new \ReflectionClass(Application::class); + + $property = $reflector->getProperty('application'); + $property->setAccessible(true); + $property->setValue($application, $container); + + $sandbox = new Sandbox($application); + + $reflector = new \ReflectionClass(Sandbox::class); + + $property = $reflector->getProperty('snapshot'); + $property->setAccessible(true); + $property->setValue($sandbox, $application); + + return $sandbox; + } + + protected function getContainer() + { + $container = m::mock(Container::class); + $container->shouldReceive('bootstrapWith') + ->andReturnNull(); + + return $container; + } + + protected function getApplication() + { + $application = m::mock(Application::class); + $application->shouldReceive('getApplication') + ->andReturn($this->getContainer()); + $application->shouldReceive('getFramework') + ->andReturn('test'); + + $reflector = new \ReflectionClass(Application::class); + + $property = $reflector->getProperty('application'); + $property->setAccessible(true); + $property->setValue($application, $this->getContainer()); + + return $application; + } +} diff --git a/tests/Server/TableTest.php b/tests/Server/TableTest.php deleted file mode 100644 index 9af19080..00000000 --- a/tests/Server/TableTest.php +++ /dev/null @@ -1,34 +0,0 @@ -add($name = 'foo', $swooleTable); - - $this->assertSame($swooleTable, $table->get($name)); - } - - public function testGetAll() - { - $swooleTable = m::mock(SwooleTable::class); - - $table = new Table; - $table->add($foo = 'foo', $swooleTable); - $table->add($bar = 'bar', $swooleTable); - - $this->assertSame(2, count($table->getAll())); - $this->assertSame($swooleTable, $table->getAll()[$foo]); - $this->assertSame($swooleTable, $table->getAll()[$bar]); - } -} diff --git a/tests/SocketIO/PacketTest.php b/tests/SocketIO/PacketTest.php new file mode 100644 index 00000000..484b2d9b --- /dev/null +++ b/tests/SocketIO/PacketTest.php @@ -0,0 +1,91 @@ +assertSame($type, Packet::getSocketType($packet)); + + $type = 3; + $packet = $type . 'probe'; + $this->assertSame($type, Packet::getSocketType($packet)); + } + + public function testGetNullSocketType() + { + $type = 7; + $packet = $type . 'probe'; + $this->assertNull(Packet::getSocketType($packet)); + + $packet = ''; + $this->assertNull(Packet::getSocketType($packet)); + } + + public function testGetPayload() + { + $packet = '42["foo","bar"]'; + $this->assertSame([ + 'event' => 'foo', + 'data' => 'bar' + ], Packet::getPayload($packet)); + + $packet = '42["foo", "bar"]'; + $this->assertSame([ + 'event' => 'foo', + 'data' => 'bar' + ], Packet::getPayload($packet)); + + $packet = '42["foo",{"message":"test"}]'; + $this->assertSame([ + 'event' => 'foo', + 'data' => [ + 'message' => 'test' + ] + ], Packet::getPayload($packet)); + + $packet = '42["foo", {"message":"test"}]'; + $this->assertSame([ + 'event' => 'foo', + 'data' => [ + 'message' => 'test' + ] + ], Packet::getPayload($packet)); + + $packet = '42["foo"]'; + $this->assertSame([ + 'event' => 'foo', + 'data' => null + ], Packet::getPayload($packet)); + } + + public function testGetNullPayload() + { + $packet = ''; + $this->assertNull(Packet::getPayload($packet)); + + $packet = '2probe'; + $this->assertNull(Packet::getPayload($packet)); + } + + public function testIsSocketType() + { + $packet = '2probe'; + $this->assertTrue(Packet::isSocketType($packet, 'ping')); + + $packet = '3probe'; + $this->assertTrue(Packet::isSocketType($packet, 'pong')); + + $packet = '42["foo", "bar"]'; + $this->assertTrue(Packet::isSocketType($packet, 'message')); + + $packet = '0probe'; + $this->assertFalse(Packet::isSocketType($packet, 'ping')); + } +} diff --git a/tests/SocketIO/ParserTest.php b/tests/SocketIO/ParserTest.php new file mode 100644 index 00000000..f1550cae --- /dev/null +++ b/tests/SocketIO/ParserTest.php @@ -0,0 +1,113 @@ +assertSame(json_encode([ + 'event' => $event, + 'data' => $data + ]), $parser->encode($event, $data)); + } + + public function testBaseDecode() + { + $payload = json_encode($data = [ + 'event' => 'foo', + 'data' => 'bar' + ]); + $frame = m::mock(Frame::class); + $frame->data = $payload; + + $parser = new Parser; + $this->assertSame($data, $parser->decode($frame)); + } + + public function testSocketEncode() + { + $event = 'foo'; + $data = 'bar'; + + $parser = new SocketIOParser; + $this->assertSame('42["foo","bar"]', $parser->encode($event, $data)); + + $data = ['message' => 'test']; + $this->assertSame('42["foo",{"message":"test"}]', $parser->encode($event, $data)); + + $data = (object) ['message' => 'test']; + $this->assertSame('42["foo",{"message":"test"}]', $parser->encode($event, $data)); + } + + public function testSocketDecode() + { + $payload = '42["foo",{"message":"test"}]'; + $frame = m::mock(Frame::class); + $frame->data = $payload; + + $parser = new SocketIOParser; + $this->assertSame([ + 'event' => 'foo', + 'data' => [ + 'message' => 'test' + ] + ], $parser->decode($frame)); + + $payload = '42["foo","bar"]'; + $frame->data = $payload; + $this->assertSame([ + 'event' => 'foo', + 'data' => 'bar' + ], $parser->decode($frame)); + } + + public function testExecute() + { + $frame = m::mock(Frame::class); + $server = new Server('0.0.0.0'); + + $app = App::shouldReceive('call')->once(); + + $parser = new SocketIOParser; + $skip = $parser->execute($server, $frame); + } + + public function testHeartbeatStrategy() + { + $payload = '42["foo","bar"]'; + + $frame = m::mock(Frame::class); + $frame->data = $payload; + $frame->fd = 1; + + // // will lead to mockery bug + // $server = m::mock(swoole_websocket_server::class); + // $server->shouldReceive('push')->once(); + + $server = new Server('0.0.0.0'); + + $strategy = new HeartbeatStrategy; + $this->assertFalse($strategy->handle($server, $frame)); + + $frame->data = '3'; + $this->assertTrue($strategy->handle($server, $frame)); + + // // will lead segmentation fault + // $frame->data = '2probe'; + // $this->assertTrue($strategy->handle($server, $frame)); + } +} diff --git a/tests/Table/TableTest.php b/tests/Table/TableTest.php new file mode 100644 index 00000000..35efc296 --- /dev/null +++ b/tests/Table/TableTest.php @@ -0,0 +1,44 @@ +add($name = 'foo', $table); + + $this->assertSame($table, $swooleTable->get($name)); + } + + public function testGetAll() + { + $table = m::mock(Table::class); + + $swooleTable = new SwooleTable; + $swooleTable->add($foo = 'foo', $table); + $swooleTable->add($bar = 'bar', $table); + + $this->assertSame(2, count($swooleTable->getAll())); + $this->assertSame($table, $swooleTable->getAll()[$foo]); + $this->assertSame($table, $swooleTable->getAll()[$bar]); + } + + public function testDynamicallyGet() + { + $table = m::mock(Table::class); + + $swooleTable = new SwooleTable; + $swooleTable->add($foo = 'foo', $table); + + $this->assertSame($table, $swooleTable->$foo); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 71615442..29be6a2c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,8 @@ namespace SwooleTW\Http\Tests; +use Mockery as m; +use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\TestCase as BaseTestCase; class TestCase extends BaseTestCase @@ -9,7 +11,10 @@ class TestCase extends BaseTestCase public function tearDown() { $this->addToAssertionCount( - \Mockery::getContainer()->mockery_getExpectationCount() + m::getContainer()->mockery_getExpectationCount() ); + + Facade::clearResolvedInstances(); + parent::tearDown(); } } diff --git a/tests/Websocket/RedisRoomTest.php b/tests/Websocket/RedisRoomTest.php new file mode 100644 index 00000000..bee24bfe --- /dev/null +++ b/tests/Websocket/RedisRoomTest.php @@ -0,0 +1,123 @@ +getRedis(); + $redis->shouldReceive('keys') + ->once() + ->andReturn($keys = ['foo', 'bar']); + $redis->shouldReceive('del') + ->with($keys) + ->once(); + + $redisRoom = new RedisRoom([]); + $redisRoom->prepare($redis); + + $this->assertTrue($redisRoom->getRedis() instanceOf RedisClient); + } + + public function testInvalidTableName() + { + $redisRoom = $this->getRedisRoom(); + + $this->expectException(\InvalidArgumentException::class); + + $redisRoom->getValue(1, 'foo'); + } + + public function testAddValue() + { + $redis = $this->getRedis(); + $redis->shouldReceive('pipeline') + ->once(); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->addValue(1, ['foo', 'bar'], 'sids'); + } + + public function testAddAll() + { + $redis = $this->getRedis(); + $redis->shouldReceive('pipeline') + ->times(3); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->addAll(1, ['foo', 'bar']); + } + + public function testAdd() + { + $redis = $this->getRedis(); + $redis->shouldReceive('pipeline') + ->twice(); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->add(1, 'foo'); + } + + public function testDeleteAll() + { + $redis = $this->getRedis(); + $redis->shouldReceive('pipeline') + ->times(3); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->deleteAll(1, ['foo', 'bar']); + } + + public function testDelete() + { + $redis = $this->getRedis(); + $redis->shouldReceive('pipeline') + ->twice(); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->delete(1, 'foo'); + } + + public function testGetRooms() + { + $redis = $this->getRedis(); + $redis->shouldReceive('smembers') + ->with('swoole:sids:1') + ->once(); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->getRooms(1); + } + + public function testGetClients() + { + $redis = $this->getRedis(); + $redis->shouldReceive('smembers') + ->with('swoole:rooms:foo') + ->once(); + $redisRoom = $this->getRedisRoom($redis); + + $redisRoom->getClients('foo'); + } + + protected function getRedisRoom($redis = null) + { + $redisRoom = new RedisRoom([]); + $redisRoom->setRedis($redis ?: $this->getRedis()); + + return $redisRoom; + } + + protected function getRedis() + { + $redis = m::mock(RedisClient::class); + + return $redis; + } +} diff --git a/tests/Websocket/TableRoomTest.php b/tests/Websocket/TableRoomTest.php index 5f99a3d1..ece54d5e 100644 --- a/tests/Websocket/TableRoomTest.php +++ b/tests/Websocket/TableRoomTest.php @@ -1,6 +1,6 @@ leaveAll($names); } + public function testCallbacks() + { + $websocket = $this->getWebsocket(); + + $websocket->on('foo', function () { + return 'bar'; + }); + + $this->assertTrue($websocket->eventExists('foo')); + $this->assertFalse($websocket->eventExists('bar')); + + $this->expectException(InvalidArgumentException::class); + $websocket->on('invalid', 123); + } + + public function testReset() + { + $websocket = $this->getWebsocket(); + $websocket->setSender(1) + ->broadcast() + ->to('foo'); + + $websocket->reset(true); + + $this->assertNull($websocket->getSender()); + $this->assertFalse($websocket->getIsBroadcast()); + $this->assertSame([], $websocket->getTo()); + } + protected function getWebsocket(RoomContract $room = null) { return new Websocket($room ?? m::mock(RoomContract::class));