diff --git a/.travis.yml b/.travis.yml index 247e7bfe..38d21750 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ matrix: before_install: - echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini + - yes | pecl install swoole - phpenv config-rm xdebug.ini || return 0 install: diff --git a/CHANGELOG.md b/CHANGELOG.md index b51ddc59..8047d86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), #### Added -* *Nothing* +* [#208](https://github.com/shlinkio/shlink/issues/208) Added initial support to run shlink using [swoole](https://www.swoole.co.uk/), a non-blocking IO server which improves the performance of shlink from 4 to 10 times. + + Run shlink with `./vendor/bin/zend-expressive-swoole start` to start-up the service, which will be exposed in port `8080`. + + Adding the `-d` flag, it will be started as a background service. Then you can use the `./vendor/bin/zend-expressive-swoole stop` command in order to stop it. #### Changed diff --git a/bin/cli b/bin/cli index ea8cb5c3..3284eea8 100755 --- a/bin/cli +++ b/bin/cli @@ -3,8 +3,11 @@ declare(strict_types=1); use Interop\Container\ContainerInterface; +use Shlinkio\Shlink\Common\Exec\ExecutionContext; use Symfony\Component\Console\Application as CliApp; /** @var ContainerInterface $container */ $container = include __DIR__ . '/../config/container.php'; + +putenv(sprintf('CURRENT_SHLINK_CONTEXT=%s', ExecutionContext::CLI)); $container->get(CliApp::class)->run(); diff --git a/composer.json b/composer.json index 3d325b03..8c79a730 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "zendframework/zend-expressive-fastroute": "^3.0", "zendframework/zend-expressive-helpers": "^5.0", "zendframework/zend-expressive-platesrenderer": "^2.0", + "zendframework/zend-expressive-swoole": "^2.0", "zendframework/zend-i18n": "^2.7", "zendframework/zend-inputfilter": "^2.8", "zendframework/zend-paginator": "^2.6", diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 89961f77..951c909f 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -1,7 +1,7 @@ 'data/proxies', ], 'connection' => [ - 'user' => Common\env('DB_USER'), - 'password' => Common\env('DB_PASSWORD'), - 'dbname' => Common\env('DB_NAME', 'shlink'), + 'user' => env('DB_USER'), + 'password' => env('DB_PASSWORD'), + 'dbname' => env('DB_NAME', 'shlink'), 'charset' => 'utf8', ], ], diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 363e9210..0477f4b2 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink; use Monolog\Handler\RotatingFileHandler; +use Monolog\Handler\StreamHandler; use Monolog\Logger; use Monolog\Processor; +use Zend\Expressive\Swoole\Log\AccessLogInterface; use const PHP_EOL; return [ @@ -19,13 +21,19 @@ return [ ], 'handlers' => [ - 'rotating_file_handler' => [ + 'shlink_rotating_handler' => [ 'class' => RotatingFileHandler::class, 'level' => Logger::INFO, 'filename' => 'data/log/shlink_log.log', 'max_files' => 30, 'formatter' => 'dashed', ], + 'swoole_access_handler' => [ + 'class' => StreamHandler::class, + 'level' => Logger::INFO, + 'stream' => 'php://stdout', + 'formatter' => 'dashed', + ], ], 'processors' => [ @@ -39,9 +47,30 @@ return [ 'loggers' => [ 'Shlink' => [ - 'handlers' => ['rotating_file_handler'], + 'handlers' => ['shlink_rotating_handler'], 'processors' => ['exception_with_new_line', 'psr3'], ], + 'Swoole' => [ + 'handlers' => ['swoole_access_handler'], + 'processors' => ['psr3'], + ], + ], + ], + + 'dependencies' => [ + 'factories' => [ + 'Logger_Shlink' => Common\Factory\LoggerFactory::class, + 'Logger_Swoole' => Common\Factory\LoggerFactory::class, + + AccessLogInterface::class => Common\Logger\Swoole\AccessLogFactory::class, + ], + ], + + 'zend-expressive-swoole' => [ + 'swoole-http-server' => [ + 'logger' => [ + 'logger_name' => 'Logger_Swoole', + ], ], ], diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist index 951a3af0..47203ed0 100644 --- a/config/autoload/logger.local.php.dist +++ b/config/autoload/logger.local.php.dist @@ -1,11 +1,13 @@ [ 'handlers' => [ - 'rotating_file_handler' => [ + 'shlink_rotating_handler' => [ 'level' => Logger::DEBUG, ], ], diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 100fa714..1e7877f5 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -10,10 +10,18 @@ return [ 'middleware_pipeline' => [ 'pre-routing' => [ - 'middleware' => [ - ErrorHandler::class, - Expressive\Helper\ContentLengthMiddleware::class, - ], + 'middleware' => (function () { + $middleware = [ + ErrorHandler::class, + Expressive\Helper\ContentLengthMiddleware::class, + ]; + + if (Common\Exec\ExecutionContext::currentContextIsSwoole()) { + $middleware[] = Common\Middleware\CloseDbConnectionMiddleware::class; + } + + return $middleware; + })(), 'priority' => 12, ], 'pre-routing-rest' => [ diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php new file mode 100644 index 00000000..ba60df5f --- /dev/null +++ b/config/autoload/swoole.global.php @@ -0,0 +1,14 @@ + [ + 'enable_coroutine' => true, + + 'swoole-http-server' => [ + 'host' => '0.0.0.0', + ], + ], + +]; diff --git a/config/config.php b/config/config.php index d9d9132e..e52065d1 100644 --- a/config/config.php +++ b/config/config.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink; use Acelaya\ExpressiveErrorHandler; use Zend\ConfigAggregator; use Zend\Expressive; -use function class_exists; return (new ConfigAggregator\ConfigAggregator([ Expressive\ConfigProvider::class, @@ -14,9 +13,7 @@ return (new ConfigAggregator\ConfigAggregator([ Expressive\Router\FastRouteRouter\ConfigProvider::class, Expressive\Plates\ConfigProvider::class, Expressive\Helper\ConfigProvider::class, - class_exists(Expressive\Swoole\ConfigProvider::class) - ? Expressive\Swoole\ConfigProvider::class - : new ConfigAggregator\ArrayProvider([]), + Expressive\Swoole\ConfigProvider::class, ExpressiveErrorHandler\ConfigProvider::class, Common\ConfigProvider::class, Core\ConfigProvider::class, diff --git a/config/pipeline.php b/config/pipeline.php new file mode 100644 index 00000000..82d3aeea --- /dev/null +++ b/config/pipeline.php @@ -0,0 +1,6 @@ + + +RUN apk update + +# Install common php extensions +RUN docker-php-ext-install pdo_mysql +RUN docker-php-ext-install iconv +RUN docker-php-ext-install mbstring +RUN docker-php-ext-install calendar + +RUN apk add --no-cache --virtual sqlite-libs +RUN apk add --no-cache --virtual sqlite-dev +RUN docker-php-ext-install pdo_sqlite + +RUN apk add --no-cache --virtual icu-dev +RUN docker-php-ext-install intl + +RUN apk add --no-cache --virtual zlib-dev +RUN docker-php-ext-install zip + +RUN apk add --no-cache --virtual libmcrypt-dev +RUN docker-php-ext-install mcrypt + +RUN apk add --no-cache --virtual libpng-dev +RUN docker-php-ext-install gd + +# Install redis extension +ADD https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz /tmp/phpredis.tar.gz +RUN mkdir -p /usr/src/php/ext/redis\ + && tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1 +# configure and install +RUN docker-php-ext-configure redis\ + && docker-php-ext-install redis +# cleanup +RUN rm /tmp/phpredis.tar.gz + +# Install memcached extension +RUN apk add --no-cache --virtual cyrus-sasl-dev +RUN apk add --no-cache --virtual libmemcached-dev +ADD https://github.com/php-memcached-dev/php-memcached/archive/php7.tar.gz /tmp/memcached.tar.gz +RUN mkdir -p /usr/src/php/ext/memcached\ + && tar xf /tmp/memcached.tar.gz -C /usr/src/php/ext/memcached --strip-components=1 +# configure and install +RUN docker-php-ext-configure memcached\ + && docker-php-ext-install memcached +# cleanup +RUN rm /tmp/memcached.tar.gz + +# Install APCu extension +ADD https://pecl.php.net/get/apcu-5.1.3.tgz /tmp/apcu.tar.gz +RUN mkdir -p /usr/src/php/ext/apcu\ + && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 +# configure and install +RUN docker-php-ext-configure apcu\ + && docker-php-ext-install apcu +# cleanup +RUN rm /tmp/apcu.tar.gz + +# Install APCu-BC extension +ADD https://pecl.php.net/get/apcu_bc-1.0.3.tgz /tmp/apcu_bc.tar.gz +RUN mkdir -p /usr/src/php/ext/apcu-bc\ + && tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1 +# configure and install +RUN docker-php-ext-configure apcu-bc\ + && docker-php-ext-install apcu-bc +# cleanup +RUN rm /tmp/apcu_bc.tar.gz + +# Load APCU.ini before APC.ini +RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini +RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini + +# Install swoole +# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233 +RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \ + pecl install swoole && \ + docker-php-ext-enable swoole && \ + apk del .phpize-deps + +# Install composer +RUN php -r "readfile('https://getcomposer.org/installer');" | php +RUN chmod +x composer.phar +RUN mv composer.phar /usr/local/bin/composer + +# Make home directory writable by anyone +RUN chmod 777 /home + +VOLUME /home/shlink +WORKDIR /home/shlink + +# Expose swoole port +EXPOSE 8080 + +CMD /usr/local/bin/composer update && \ + # When restarting the container, swoole might think it is already in execution + # This forces the app to be started every second until the exit code is 0 + until php ./vendor/bin/zend-expressive-swoole start; do sleep 1 ; done diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist index b347b9e8..6e40c113 100644 --- a/docker-compose.override.yml.dist +++ b/docker-compose.override.yml.dist @@ -6,3 +6,9 @@ services: volumes: - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro + + shlink_swoole: + user: 1000:1000 + volumes: + - /etc/passwd:/etc/passwd:ro + - /etc/group:/etc/group:ro diff --git a/docker-compose.yml b/docker-compose.yml index 217ae629..3424d925 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,18 @@ services: links: - shlink_db + shlink_swoole: + container_name: shlink_swoole + build: + context: . + dockerfile: ./data/infra/swoole.Dockerfile + ports: + - "8080:8080" + volumes: + - ./:/home/shlink + links: + - shlink_db + shlink_db: container_name: shlink_db build: diff --git a/indocker b/indocker index c4348e6c..77a06d33 100755 --- a/indocker +++ b/indocker @@ -1,2 +1,2 @@ #!/usr/bin/env bash -docker exec -it shlink_php /bin/sh -c "cd /home/shlink/www && $*" +docker exec -it shlink_swoole /bin/sh -c "$*" diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php index b03520a3..64e20563 100644 --- a/module/Common/config/dependencies.config.php +++ b/module/Common/config/dependencies.config.php @@ -23,7 +23,6 @@ return [ EntityManager::class => Factory\EntityManagerFactory::class, GuzzleClient::class => InvokableFactory::class, Cache::class => Factory\CacheFactory::class, - 'Logger_Shlink' => Factory\LoggerFactory::class, Filesystem::class => InvokableFactory::class, Reader::class => ConfigAbstractFactory::class, @@ -31,6 +30,7 @@ return [ Template\Extension\TranslatorExtension::class => ConfigAbstractFactory::class, Middleware\LocaleMiddleware::class => ConfigAbstractFactory::class, + Middleware\CloseDbConnectionMiddleware::class => ConfigAbstractFactory::class, IpAddress::class => Middleware\IpAddressMiddlewareFactory::class, Image\ImageBuilder::class => Image\ImageBuilderFactory::class, @@ -78,6 +78,7 @@ return [ Template\Extension\TranslatorExtension::class => ['translator'], Middleware\LocaleMiddleware::class => ['translator'], + Middleware\CloseDbConnectionMiddleware::class => ['em'], IpGeolocation\IpApiLocationResolver::class => ['httpClient'], IpGeolocation\GeoLite2LocationResolver::class => [Reader::class], diff --git a/module/Common/src/Exec/ExecutionContext.php b/module/Common/src/Exec/ExecutionContext.php new file mode 100644 index 00000000..214d7571 --- /dev/null +++ b/module/Common/src/Exec/ExecutionContext.php @@ -0,0 +1,18 @@ +get('config'); - return Translator::factory(isset($config['translator']) ? $config['translator'] : []); + return Translator::factory($config['translator'] ?? []); } } diff --git a/module/Common/src/Logger/Swoole/AccessLogFactory.php b/module/Common/src/Logger/Swoole/AccessLogFactory.php new file mode 100644 index 00000000..60822762 --- /dev/null +++ b/module/Common/src/Logger/Swoole/AccessLogFactory.php @@ -0,0 +1,51 @@ +has('config') ? $container->get('config') : []; + $config = $config['zend-expressive-swoole']['swoole-http-server']['logger'] ?? []; + + return new Log\Psr3AccessLogDecorator( + $this->getLogger($container, $config), + $this->getFormatter($container, $config), + $config['use-hostname-lookups'] ?? false + ); + } + + private function getLogger(ContainerInterface $container, array $config): LoggerInterface + { + $loggerName = $config['logger_name'] ?? LoggerInterface::class; + return $container->has($loggerName) ? $container->get($loggerName) : new Log\StdoutLogger(); + } + + private function getFormatter(ContainerInterface $container, array $config): Log\AccessLogFormatterInterface + { + if ($container->has(Log\AccessLogFormatterInterface::class)) { + return $container->get(Log\AccessLogFormatterInterface::class); + } + + return new Log\AccessLogFormatter($config['format'] ?? Log\AccessLogFormatter::FORMAT_COMMON); + } +} diff --git a/module/Common/src/Middleware/CloseDbConnectionMiddleware.php b/module/Common/src/Middleware/CloseDbConnectionMiddleware.php new file mode 100644 index 00000000..c365da51 --- /dev/null +++ b/module/Common/src/Middleware/CloseDbConnectionMiddleware.php @@ -0,0 +1,34 @@ +em = $em; + } + + /** + * Process an incoming server request and return a response, optionally delegating + * response creation to a handler. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $handledRequest = $handler->handle($request); + $this->em->getConnection()->close(); + $this->em->clear(); + + return $handledRequest; + } +} diff --git a/module/Common/test/Logger/Swoole/AccessLogFactoryTest.php b/module/Common/test/Logger/Swoole/AccessLogFactoryTest.php new file mode 100644 index 00000000..986c117a --- /dev/null +++ b/module/Common/test/Logger/Swoole/AccessLogFactoryTest.php @@ -0,0 +1,138 @@ +factory = new AccessLogFactory(); + } + + /** + * @test + */ + public function createsService() + { + $service = ($this->factory)(new ServiceManager(), ''); + $this->assertInstanceOf(Psr3AccessLogDecorator::class, $service); + } + + /** + * @test + * @dataProvider provideLoggers + * @param array $config + * @param string|LoggerInterface $expectedLogger + */ + public function wrapsProperLogger(array $config, $expectedLogger) + { + $service = ($this->factory)(new ServiceManager(['services' => $config]), ''); + + $ref = new ReflectionObject($service); + $loggerProp = $ref->getProperty('logger'); + $loggerProp->setAccessible(true); + $logger = $loggerProp->getValue($service); + + if (is_string($expectedLogger)) { + $this->assertInstanceOf($expectedLogger, $logger); + } else { + $this->assertSame($expectedLogger, $logger); + } + } + + public function provideLoggers(): iterable + { + yield 'without-any-logger' => [[], StdoutLogger::class]; + yield 'with-standard-logger' => (function () { + $logger = new NullLogger(); + return [[LoggerInterface::class => $logger], $logger]; + })(); + yield 'with-custom-logger' => (function () { + $logger = new NullLogger(); + return [[ + 'config' => [ + 'zend-expressive-swoole' => [ + 'swoole-http-server' => [ + 'logger' => [ + 'logger_name' => 'my-logger', + ], + ], + ], + ], + 'my-logger' => $logger, + ], $logger]; + })(); + } + + /** + * @test + * @dataProvider provideFormatters + * @param array $config + * @param string|AccessLogFormatterInterface $expectedFormatter + */ + public function wrappsProperFormatter(array $config, $expectedFormatter, string $expectedFormat) + { + $service = ($this->factory)(new ServiceManager(['services' => $config]), ''); + + $ref = new ReflectionObject($service); + $formatterProp = $ref->getProperty('formatter'); + $formatterProp->setAccessible(true); + $formatter = $formatterProp->getValue($service); + + $ref = new ReflectionObject($formatter); + $formatProp = $ref->getProperty('format'); + $formatProp->setAccessible(true); + $format = $formatProp->getValue($formatter); + + if (is_string($expectedFormatter)) { + $this->assertInstanceOf($expectedFormatter, $formatter); + } else { + $this->assertSame($expectedFormatter, $formatter); + } + $this->assertSame($expectedFormat, $format); + } + + public function provideFormatters(): iterable + { + yield 'with-registered-formatter-and-default-format' => (function () { + $formatter = new AccessLogFormatter(); + return [[AccessLogFormatterInterface::class => $formatter], $formatter, AccessLogFormatter::FORMAT_COMMON]; + })(); + yield 'with-registered-formatter-and-custom-format' => (function () { + $formatter = new AccessLogFormatter(AccessLogFormatter::FORMAT_AGENT); + return [[AccessLogFormatterInterface::class => $formatter], $formatter, AccessLogFormatter::FORMAT_AGENT]; + })(); + yield 'with-no-formatter-and-not-configured-format' => [ + [], + AccessLogFormatter::class, + AccessLogFormatter::FORMAT_COMMON, + ]; + yield 'with-no-formatter-and-configured-format' => [[ + 'config' => [ + 'zend-expressive-swoole' => [ + 'swoole-http-server' => [ + 'logger' => [ + 'format' => AccessLogFormatter::FORMAT_COMBINED_DEBIAN, + ], + ], + ], + ], + ], AccessLogFormatter::class, AccessLogFormatter::FORMAT_COMBINED_DEBIAN]; + } +} diff --git a/module/Common/test/Middleware/CloseDbConnectionMiddlewareTest.php b/module/Common/test/Middleware/CloseDbConnectionMiddlewareTest.php new file mode 100644 index 00000000..36bdff87 --- /dev/null +++ b/module/Common/test/Middleware/CloseDbConnectionMiddlewareTest.php @@ -0,0 +1,56 @@ +handler = $this->prophesize(RequestHandlerInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + + $this->middleware = new CloseDbConnectionMiddleware($this->em->reveal()); + } + + /** + * @test + */ + public function connectionIsClosedWhenMiddlewareIsProcessed() + { + $req = ServerRequestFactory::fromGlobals(); + $resp = new Response(); + + $conn = $this->prophesize(Connection::class); + $closeConn = $conn->close()->will(function () { + }); + $getConn = $this->em->getConnection()->willReturn($conn->reveal()); + $clear = $this->em->clear()->will(function () { + }); + $handle = $this->handler->handle($req)->willReturn($resp); + + $result = $this->middleware->process($req, $this->handler->reveal()); + + $this->assertSame($result, $resp); + $getConn->shouldHaveBeenCalledOnce(); + $closeConn->shouldHaveBeenCalledOnce(); + $clear->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 89627675..4a7edcfb 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -107,13 +107,7 @@ class UrlShortener implements UrlShortenerInterface } } - /** - * Tries to perform a GET request to provided url, returning true on success and false on failure - * - * @param UriInterface $url - * @return void - */ - private function checkUrlExists(UriInterface $url) + private function checkUrlExists(UriInterface $url): void { try { $this->httpClient->request('GET', $url, ['allow_redirects' => [ @@ -124,12 +118,6 @@ class UrlShortener implements UrlShortenerInterface } } - /** - * Generates the unique shortcode for an autoincrement ID - * - * @param float $id - * @return string - */ private function convertAutoincrementIdToShortCode(float $id): string { $id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short @@ -145,7 +133,7 @@ class UrlShortener implements UrlShortenerInterface return $this->chars[(int) $id] . $code; } - private function processCustomSlug($customSlug) + private function processCustomSlug(?string $customSlug): ?string { if ($customSlug === null) { return null; diff --git a/phpcs.xml b/phpcs.xml index e1d56d5a..40a10e91 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -56,4 +56,5 @@ config public/index.php config/params/* + public/index.php diff --git a/public/index.php b/public/index.php index fa5d778c..f9469830 100644 --- a/public/index.php +++ b/public/index.php @@ -2,8 +2,11 @@ declare(strict_types=1); use Psr\Container\ContainerInterface; +use Shlinkio\Shlink\Common\Exec\ExecutionContext; use Zend\Expressive\Application; /** @var ContainerInterface $container */ $container = include __DIR__ . '/../config/container.php'; + +putenv(sprintf('CURRENT_SHLINK_CONTEXT=%s', ExecutionContext::WEB)); $container->get(Application::class)->run();