From 3cca5c43222d329017894dc21840a082fe1b5beb Mon Sep 17 00:00:00 2001 From: Konrad Obal Date: Sun, 7 Oct 2018 15:46:39 +0200 Subject: [PATCH] feat(daemon-mode): Daemonize Swoole HTTP server (#8) See commands: ```bash $ bin/console swoole:server:start # starts swoole http server in background $ bin/console swoole:server:stop # stops swoole http server running in background $ bin/console swoole:server:reload # reloads swoole http server's workers and project classes (not infrastructure/vendor ones) ``` --- .gitignore | 1 + .travis.yml | 1 + composer.lock | 2 +- .../Command/AbstractServerStartCommand.php | 32 ++++++--- .../Bundle/Command/ServerReloadCommand.php | 65 +++++++++++++++++ .../Bundle/Command/ServerRunCommand.php | 2 +- .../Bundle/Command/ServerStartCommand.php | 69 +++++++++++++++++++ .../Bundle/Command/ServerStopCommand.php | 65 +++++++++++++++++ .../Bundle/Resources/config/services.yaml | 11 +++ src/Server/HttpServer.php | 43 ++++++++++-- src/Server/HttpServerConfiguration.php | 52 +++++++++++++- src/functions.php | 27 ++++---- .../Symfony/{app => }/TestAppKernel.php | 20 ++++-- tests/Fixtures/Symfony/app/console | 2 +- .../001-swoole-server-start-response-stop.sh | 24 +++++++ 15 files changed, 380 insertions(+), 36 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/Command/ServerReloadCommand.php create mode 100644 src/Bridge/Symfony/Bundle/Command/ServerStartCommand.php create mode 100644 src/Bridge/Symfony/Bundle/Command/ServerStopCommand.php rename tests/Fixtures/Symfony/{app => }/TestAppKernel.php (84%) create mode 100755 tests/Server/001-swoole-server-start-response-stop.sh diff --git a/.gitignore b/.gitignore index 4af9a921..7d3f4b60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor +swoole.pid .php_cs.cache clover.xml diff --git a/.travis.yml b/.travis.yml index b93f75fa..27ca7b0f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -48,6 +48,7 @@ script: - 'if [[ "$COVERAGE" != "1" ]]; then composer test; fi' - 'if [[ "$COVERAGE" == "1" ]]; then composer analyse; fi' - 'if [[ "$COVERAGE" == "1" ]]; then composer code-coverage; fi' +- 'for f in ./tests/Server/*.sh; do echo "[Test] $f"; bash "$f" -H || return 1; done;' before_deploy: - >- diff --git a/composer.lock b/composer.lock index 1f5fdc49..f65b4905 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "29379ef450a3e42f149a6ac9a61c0aa0", + "content-hash": "6be31d42688c47ec8ac9c65313fd803f", "packages": [ { "name": "beberlei/assert", diff --git a/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php b/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php index 98fb9d8f..b4282bdf 100644 --- a/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/AbstractServerStartCommand.php @@ -26,7 +26,7 @@ abstract class AbstractServerStartCommand extends Command private $serverFactory; private $serverConfiguration; private $bootManager; - private $parameterBag; + protected $parameterBag; /** * @param HttpServer $server @@ -75,8 +75,7 @@ protected function configure(): void ->addOption('serve-static', 's', InputOption::VALUE_NONE, 'Enables serving static content from public directory.') ->addOption('public-dir', null, InputOption::VALUE_REQUIRED, 'Public directory', $this->getDefaultPublicDir()) ->addOption('trusted-hosts', null, InputOption::VALUE_REQUIRED, 'Trusted hosts', $this->parameterBag->get('swoole.http_server.trusted_hosts')) - ->addOption('trusted-proxies', null, InputOption::VALUE_REQUIRED, 'Trusted proxies', $this->parameterBag->get('swoole.http_server.trusted_proxies')) - ; + ->addOption('trusted-proxies', null, InputOption::VALUE_REQUIRED, 'Trusted proxies', $this->parameterBag->get('swoole.http_server.trusted_proxies')); } /** @@ -95,6 +94,11 @@ final protected function execute(InputInterface $input, OutputInterface $output) $this->prepareServerConfiguration($this->serverConfiguration, $input); + if ($this->server->isRunning()) { + $io->error('Swoole HTTP Server is already running'); + exit(1); + } + $this->server->attach($this->serverFactory->make( $this->serverConfiguration->getDefaultSocket(), $this->serverConfiguration->getRunningMode() @@ -109,11 +113,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) $io->success(\sprintf('Swoole HTTP Server started on http://%s', $this->serverConfiguration->getDefaultSocket()->addressPort())); $io->table(['Configuration', 'Values'], $this->prepareConfigurationRowsToPrint($this->serverConfiguration, $runtimeConfiguration)); - if ($this->server->start()) { - $io->success('Swoole HTTP Server has been successfully shutdown.'); - } else { - $io->error('Failure during starting Swoole HTTP Server.'); - } + $this->startServer($this->serverConfiguration, $this->server, $io); } /** @@ -233,4 +233,20 @@ protected function prepareConfigurationRowsToPrint(HttpServerConfiguration $serv return $rows; } + + /** + * @param HttpServerConfiguration $serverConfiguration + * @param HttpServer $server + * @param SymfonyStyle $io + * + * @throws \Assert\AssertionFailedException + */ + protected function startServer(HttpServerConfiguration $serverConfiguration, HttpServer $server, SymfonyStyle $io): void + { + if ($server->start()) { + $io->success('Swoole HTTP Server has been successfully shutdown.'); + } else { + $io->error('Failure during starting Swoole HTTP Server.'); + } + } } diff --git a/src/Bridge/Symfony/Bundle/Command/ServerReloadCommand.php b/src/Bridge/Symfony/Bundle/Command/ServerReloadCommand.php new file mode 100644 index 00000000..65e25b1c --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/ServerReloadCommand.php @@ -0,0 +1,65 @@ +server = $server; + $this->serverConfiguration = $serverConfiguration; + $this->parameterBag = $parameterBag; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this->setName('swoole:server:reload') + ->setDescription("Reloads a local Swoole HTTP server's workers running in background. It will reload only classes not loaded before server initialization.") + ->addOption('pid_file', null, InputOption::VALUE_REQUIRED, 'Pid file', $this->parameterBag->get('kernel.project_dir').'/var/swoole.pid'); + } + + /** + * {@inheritdoc} + * + * @throws \Assert\AssertionFailedException + */ + protected function execute(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + $this->serverConfiguration->daemonize($input->getOption('pid_file')); + + try { + $this->server->reload(); + } catch (Throwable $ex) { + $io->error($ex->getMessage()); + exit(1); + } + + $io->success('Swoole HTTP Server\'s workers reloaded successfully'); + } +} diff --git a/src/Bridge/Symfony/Bundle/Command/ServerRunCommand.php b/src/Bridge/Symfony/Bundle/Command/ServerRunCommand.php index 18391ff2..267da2d7 100644 --- a/src/Bridge/Symfony/Bundle/Command/ServerRunCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/ServerRunCommand.php @@ -12,7 +12,7 @@ final class ServerRunCommand extends AbstractServerStartCommand protected function configure(): void { $this->setName('swoole:server:run') - ->setDescription('Runs a local swoole http server'); + ->setDescription('Runs a local Swoole HTTP server.'); parent::configure(); } diff --git a/src/Bridge/Symfony/Bundle/Command/ServerStartCommand.php b/src/Bridge/Symfony/Bundle/Command/ServerStartCommand.php new file mode 100644 index 00000000..552b2070 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/ServerStartCommand.php @@ -0,0 +1,69 @@ +setName('swoole:server:start') + ->setDescription('Runs a local Swoole HTTP server in background.') + ->addOption('pid_file', null, InputOption::VALUE_REQUIRED, 'Pid file', $this->parameterBag->get('kernel.project_dir').'/var/swoole.pid'); + + parent::configure(); + } + + /** + * {@inheritdoc} + */ + protected function prepareServerConfiguration(HttpServerConfiguration $serverConfiguration, InputInterface $input): void + { + /** @var string|null $pidFile */ + $pidFile = $input->getOption('pid_file'); + $serverConfiguration->daemonize($pidFile); + + parent::prepareServerConfiguration($serverConfiguration, $input); + } + + /** + * {@inheritdoc} + */ + protected function startServer(HttpServerConfiguration $serverConfiguration, HttpServer $server, SymfonyStyle $io): void + { + if (!$serverConfiguration->existsPidFile() && !\touch($serverConfiguration->getPidFile())) { + throw new RuntimeException(\sprintf('Could not create pid file "%s".', $serverConfiguration->getPid())); + } + + // Output stream `php://stdout` must be closed + $this->forceCloseOutputStream($io); + + $server->start(); + } + + private function forceCloseOutputStream(SymfonyStyle $io): void + { + /** @var ConsoleOutput $consoleOutput */ + $consoleOutput = &get_object_property($io, 'output', OutputStyle::class); + + /** @var resource $stream */ + $stream = &get_object_property($consoleOutput, 'stream', StreamOutput::class); + + \fclose($stream); + } +} diff --git a/src/Bridge/Symfony/Bundle/Command/ServerStopCommand.php b/src/Bridge/Symfony/Bundle/Command/ServerStopCommand.php new file mode 100644 index 00000000..a05153d2 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/ServerStopCommand.php @@ -0,0 +1,65 @@ +server = $server; + $this->serverConfiguration = $serverConfiguration; + $this->parameterBag = $parameterBag; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this->setName('swoole:server:stop') + ->setDescription('Stops a local Swoole HTTP server running in background') + ->addOption('pid_file', null, InputOption::VALUE_REQUIRED, 'Pid file', $this->parameterBag->get('kernel.project_dir').'/var/swoole.pid'); + } + + /** + * {@inheritdoc} + * + * @throws \Assert\AssertionFailedException + */ + protected function execute(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + $this->serverConfiguration->daemonize($input->getOption('pid_file')); + + try { + $this->server->shutdown(); + } catch (Throwable $ex) { + $io->error($ex->getMessage()); + exit(1); + } + + $io->success('Swoole server shutdown successfully'); + } +} diff --git a/src/Bridge/Symfony/Bundle/Resources/config/services.yaml b/src/Bridge/Symfony/Bundle/Resources/config/services.yaml index 19322313..4b9036ef 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/services.yaml +++ b/src/Bridge/Symfony/Bundle/Resources/config/services.yaml @@ -39,6 +39,17 @@ services: arguments: $configurator: '@K911\Swoole\Server\Configurator\WithRequestHandler' + 'K911\Swoole\Bridge\Symfony\Bundle\Command\ServerStartCommand': + tags: ['console.command'] + arguments: + $serverFactory: '@swoole_bundle.server.http_server.factory' + + 'K911\Swoole\Bridge\Symfony\Bundle\Command\ServerStopCommand': + tags: ['console.command'] + + 'K911\Swoole\Bridge\Symfony\Bundle\Command\ServerReloadCommand': + tags: ['console.command'] + 'K911\Swoole\Bridge\Symfony\Bundle\Command\ServerRunCommand': tags: ['console.command'] arguments: diff --git a/src/Server/HttpServer.php b/src/Server/HttpServer.php index 8175009e..79b1b732 100644 --- a/src/Server/HttpServer.php +++ b/src/Server/HttpServer.php @@ -5,12 +5,13 @@ namespace K911\Swoole\Server; use Assert\Assertion; +use RuntimeException; use Swoole\Http\Server; +use Swoole\Process; +use Throwable; final class HttpServer { - private const SWOOLE_HTTP_SERVER_HAS_NOT_BEEN_INITIALIZED_MESSAGE = 'Swoole HTTP Server has not been setup yet. Please use setup or attach method.'; - /** * @var Server|null */ @@ -46,7 +47,7 @@ public function attach(Server $server): void */ public function start(): bool { - Assertion::isInstanceOf($this->server, Server::class, self::SWOOLE_HTTP_SERVER_HAS_NOT_BEEN_INITIALIZED_MESSAGE); + Assertion::isInstanceOf($this->server, Server::class, 'Swoole HTTP Server has not been setup yet. Please use attach method.'); return $this->running = $this->server->start(); } @@ -56,9 +57,27 @@ public function start(): bool */ public function shutdown(): void { - Assertion::isInstanceOf($this->server, Server::class, self::SWOOLE_HTTP_SERVER_HAS_NOT_BEEN_INITIALIZED_MESSAGE); + if ($this->server instanceof Server) { + $this->server->shutdown(); + } elseif ($this->isRunningInBackground()) { + Process::kill($this->configuration->getPid(), 15); // SIGTERM + } else { + throw new RuntimeException('Swoole HTTP Server has not been running.'); + } + } - $this->server->shutdown(); + /** + * @throws \Assert\AssertionFailedException + */ + public function reload(): void + { + if ($this->server instanceof Server) { + $this->server->reload(); + } elseif ($this->isRunningInBackground()) { + Process::kill($this->configuration->getPid(), 10); // SIGUSR1 + } else { + throw new RuntimeException('Swoole HTTP Server has not been running.'); + } } /** @@ -66,6 +85,18 @@ public function shutdown(): void */ public function isRunning(): bool { - return $this->running || $this->configuration->existsPidFile(); + return $this->running || $this->isRunningInBackground(); + } + + /** + * @return bool + */ + private function isRunningInBackground(): bool + { + try { + return Process::kill($this->configuration->getPid(), 0); + } catch (Throwable $ex) { + return false; + } } } diff --git a/src/Server/HttpServerConfiguration.php b/src/Server/HttpServerConfiguration.php index beb17973..68abd25d 100644 --- a/src/Server/HttpServerConfiguration.php +++ b/src/Server/HttpServerConfiguration.php @@ -17,6 +17,7 @@ final class HttpServerConfiguration */ private const SWOOLE_HTTP_SERVER_CONFIGURATION = [ 'reactor_count' => 'reactor_num', + 'daemonize' => 'daemonize', 'worker_count' => 'worker_num', 'serve_static' => 'enable_static_handler', 'public_dir' => 'document_root', @@ -109,6 +110,10 @@ private function validateSetting(string $key, $value): void Assertion::inArray($value, \array_keys(self::SWOOLE_SERVE_STATIC)); } + if ('daemonize' === $key) { + Assertion::boolean($value); + } + if ('public_dir' === $key) { Assertion::directory($value, 'Public directory does not exists. Tried "%s".'); } @@ -158,7 +163,8 @@ private function setSettings(array $settings): void } } - Assertion::false(isset($settings['serve_static']) && 'off' !== $settings['serve_static'] && !isset($settings['public_dir']), 'Enabling static files serving requires providing "public_dir" setting.'); + Assertion::false($this->isDaemon() && !$this->hasPidFile(), 'Pid file is required when using daemon mode'); + Assertion::false($this->servingStaticContent() && !$this->hasPublicDir(), 'Enabling static files serving requires providing "public_dir" setting.'); } public function getRunningMode(): string @@ -173,7 +179,12 @@ public function hasPublicDir(): bool public function hasPidFile(): bool { - return isset($this->settings['public_dir']); + return isset($this->settings['pid_file']); + } + + public function servingStaticContent(): bool + { + return isset($this->settings['serve_static']) && 'off' !== $this->settings['serve_static']; } public function existsPidFile(): bool @@ -181,6 +192,22 @@ public function existsPidFile(): bool return $this->hasPidFile() && \file_exists($this->getPidFile()); } + /** + * @throws \Assert\AssertionFailedException + * + * @return int + */ + public function getPid(): int + { + Assertion::true($this->existsPidFile(), 'Could not get pid file. It does not exists or server is not running in background.'); + + /** @var string $contents */ + $contents = \file_get_contents($this->getPidFile()); + Assertion::numeric($contents, 'Contents in pid file is not an integer or it is empty'); + + return (int) $contents; + } + /** * @throws \Assert\AssertionFailedException * @@ -276,4 +303,25 @@ public function getSwooleDocumentRoot(): ?string { return 'default' === $this->settings['serve_static'] ? $this->settings['public_dir'] : null; } + + public function isDaemon(): bool + { + return isset($this->settings['daemonize']); + } + + /** + * @param null|string $pidFile + * + * @throws \Assert\AssertionFailedException + */ + public function daemonize(?string $pidFile = null): void + { + $settings = ['daemonize' => true]; + + if (null !== $pidFile) { + $settings['pid_file'] = $pidFile; + } + + $this->setSettings($settings); + } } diff --git a/src/functions.php b/src/functions.php index 8ca972ef..5dc42815 100644 --- a/src/functions.php +++ b/src/functions.php @@ -4,37 +4,40 @@ namespace K911\Swoole; +use Closure; use OutOfRangeException; /** * Replaces object property with provided value. * Property may not be public. * - * @param object $obj - * @param string $propertyName - * @param mixed $newValue + * @param object $obj + * @param string $propertyName + * @param mixed $newValue + * @param null|string $scope class scope useful when property is inherited */ -function replace_object_property(object $obj, string $propertyName, $newValue): void +function replace_object_property(object $obj, string $propertyName, $newValue, ?string $scope = null): void { - (function (string $propertyName, $newValue): void { + Closure::bind(function (string $propertyName, $newValue): void { $this->$propertyName = $newValue; - })->call($obj, $propertyName, $newValue); + }, $obj, $scope ?? $obj)($propertyName, $newValue); } /** - * Get object property. + * Get object property (even by reference). * Property may not be public. * - * @param object $obj - * @param string $propertyName + * @param object $obj + * @param string $propertyName + * @param null|string $scope class scope useful when property is inherited * * @return mixed */ -function get_object_property(object $obj, string $propertyName) +function &get_object_property(object $obj, string $propertyName, ?string $scope = null) { - return (function (string $propertyName) { + return Closure::bind(function &(string $propertyName) { return $this->$propertyName; - })->call($obj, $propertyName); + }, $obj, $scope ?? $obj)($propertyName); } /** diff --git a/tests/Fixtures/Symfony/app/TestAppKernel.php b/tests/Fixtures/Symfony/TestAppKernel.php similarity index 84% rename from tests/Fixtures/Symfony/app/TestAppKernel.php rename to tests/Fixtures/Symfony/TestAppKernel.php index 713bffff..84a41025 100644 --- a/tests/Fixtures/Symfony/app/TestAppKernel.php +++ b/tests/Fixtures/Symfony/TestAppKernel.php @@ -2,6 +2,10 @@ declare(strict_types=1); +namespace K911\Swoole\Tests\Fixtures\Symfony; + +use Exception; +use Generator; use K911\Swoole\Bridge\Symfony\Bundle\SwooleBundle; use K911\Swoole\Tests\Fixtures\Symfony\TestBundle\TestBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -54,7 +58,7 @@ public function registerBundles(): Generator */ protected function configureRoutes(RouteCollectionBuilder $routes): void { - $routes->import('routing.yml'); + $routes->import('app/routing.yml'); } /** @@ -64,9 +68,7 @@ protected function configureRoutes(RouteCollectionBuilder $routes): void */ protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void { - $c->setParameter('kernel.project_dir', __DIR__); - - $confDir = __DIR__.'/config'; + $confDir = $this->getProjectDir().'/config'; $loader->load($confDir.'/*'.self::CONFIG_EXTENSIONS, 'glob'); if (\is_dir($confDir.'/'.$this->environment)) { $loader->load($confDir.'/'.$this->environment.'/**/*'.self::CONFIG_EXTENSIONS, 'glob'); @@ -75,6 +77,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load private function getVarDir(): string { - return __DIR__.'/var'; + return $this->getProjectDir().'/var'; + } + + /** + * {@inheritdoc} + */ + public function getProjectDir(): string + { + return __DIR__.'/app'; } } diff --git a/tests/Fixtures/Symfony/app/console b/tests/Fixtures/Symfony/app/console index d1fdec3d..687a57a3 100755 --- a/tests/Fixtures/Symfony/app/console +++ b/tests/Fixtures/Symfony/app/console @@ -1,6 +1,7 @@ #!/usr/bin/env php