[Runtime] make GenericRuntime ... generic

This commit is contained in:
Nicolas Grekas 2021-03-18 20:21:19 +01:00
parent 49d23d4813
commit 33e371e24d
19 changed files with 357 additions and 59 deletions

View File

@ -38,6 +38,8 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner;
use Symfony\Component\Runtime\Runner\Symfony\ResponseRunner;
use Symfony\Component\Runtime\SymfonyRuntime;
use Symfony\Component\String\LazyString;
use Symfony\Component\String\Slugger\AsciiSlugger;
@ -79,6 +81,8 @@ return static function (ContainerConfigurator $container) {
service('argument_resolver'),
])
->tag('container.hot_path')
->tag('container.preload', ['class' => HttpKernelRunner::class])
->tag('container.preload', ['class' => ResponseRunner::class])
->tag('container.preload', ['class' => SymfonyRuntime::class])
->alias(HttpKernelInterface::class, 'http_kernel')

View File

@ -22,8 +22,12 @@ class_exists(ClosureResolver::class);
/**
* A runtime to do bare-metal PHP without using superglobals.
*
* One option named "debug" is supported; it toggles displaying errors
* and defaults to the "APP_ENV" environment variable.
* It supports the following options:
* - "debug" toggles displaying errors and defaults
* to the "APP_DEBUG" environment variable;
* - "runtimes" maps types to a GenericRuntime implementation
* that knows how to deal with each of them;
* - "error_handler" defines the class to use to handle PHP errors.
*
* The app-callable can declare arguments among either:
* - "array $context" to get a local array similar to $_SERVER;
@ -42,42 +46,48 @@ class_exists(ClosureResolver::class);
*/
class GenericRuntime implements RuntimeInterface
{
private $debug;
protected $options;
/**
* @param array {
* debug?: ?bool,
* runtimes?: ?array,
* error_handler?: string|false,
* } $options
*/
public function __construct(array $options = [])
{
$this->debug = $options['debug'] ?? $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? true;
$debug = $options['debug'] ?? $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? true;
if (!\is_bool($this->debug)) {
$this->debug = filter_var($this->debug, \FILTER_VALIDATE_BOOLEAN);
if (!\is_bool($debug)) {
$debug = filter_var($debug, \FILTER_VALIDATE_BOOLEAN);
}
if ($this->debug) {
if ($debug) {
umask(0000);
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '1';
$errorHandler = new BasicErrorHandler($this->debug);
set_error_handler($errorHandler);
if (false !== $errorHandler = ($options['error_handler'] ?? BasicErrorHandler::class)) {
$errorHandler::register($debug);
$options['error_handler'] = false;
}
} else {
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0';
}
$this->options = $options;
}
/**
* {@inheritdoc}
*/
public function getResolver(callable $callable): ResolverInterface
public function getResolver(callable $callable, \ReflectionFunction $reflector = null): ResolverInterface
{
if (!$callable instanceof \Closure) {
$callable = \Closure::fromCallable($callable);
}
$function = new \ReflectionFunction($callable);
$parameters = $function->getParameters();
$parameters = ($reflector ?? new \ReflectionFunction($callable))->getParameters();
$arguments = function () use ($parameters) {
$arguments = [];
@ -95,7 +105,7 @@ class GenericRuntime implements RuntimeInterface
return $arguments;
};
if ($this->debug) {
if ($_SERVER['APP_DEBUG']) {
return new DebugClosureResolver($callable, $arguments);
}
@ -115,15 +125,19 @@ class GenericRuntime implements RuntimeInterface
return $application;
}
if (!$application instanceof \Closure) {
if ($runtime = $this->resolveRuntime(\get_class($application))) {
return $runtime->getRunner($application);
}
if (!\is_callable($application)) {
throw new \LogicException(sprintf('"%s" doesn\'t know how to handle apps of type "%s".', get_debug_type($this), get_debug_type($application)));
}
if (!$application instanceof \Closure) {
$application = \Closure::fromCallable($application);
}
if ($this->debug && ($r = new \ReflectionFunction($application)) && $r->getNumberOfRequiredParameters()) {
if ($_SERVER['APP_DEBUG'] && ($r = new \ReflectionFunction($application)) && $r->getNumberOfRequiredParameters()) {
throw new \ArgumentCountError(sprintf('Zero argument should be required by the runner callable, but at least one is in "%s" on line "%d.', $r->getFileName(), $r->getStartLine()));
}
@ -163,8 +177,56 @@ class GenericRuntime implements RuntimeInterface
return $this;
}
if (!$runtime = $this->getRuntime($type)) {
$r = $parameter->getDeclaringFunction();
throw new \InvalidArgumentException(sprintf('Cannot resolve argument "%s $%s" in "%s" on line "%d": "%s" supports only arguments "array $context", "array $argv" and "array $request".', $type, $parameter->name, $r->getFileName(), $r->getStartLine(), get_debug_type($this)));
throw new \InvalidArgumentException(sprintf('Cannot resolve argument "%s $%s" in "%s" on line "%d": "%s" supports only arguments "array $context", "array $argv" and "array $request", or a runtime named "Symfony\Runtime\%1$sRuntime".', $type, $parameter->name, $r->getFileName(), $r->getStartLine(), get_debug_type($this)));
}
return $runtime->getArgument($parameter, $type);
}
protected static function register(self $runtime): self
{
return $runtime;
}
private function getRuntime(string $type): ?self
{
if (null === $runtime = ($this->options['runtimes'][$type] ?? null)) {
$runtime = 'Symfony\Runtime\\'.$type.'Runtime';
$runtime = class_exists($runtime) ? $runtime : $this->options['runtimes'][$type] = false;
}
if (\is_string($runtime)) {
$runtime = $runtime::register($this);
}
if ($this === $runtime) {
return null;
}
return $runtime ?: null;
}
private function resolveRuntime(string $class): ?self
{
if ($runtime = $this->getRuntime($class)) {
return $runtime;
}
foreach (class_parents($class) as $type) {
if ($runtime = $this->getRuntime($type)) {
return $runtime;
}
}
foreach (class_implements($class) as $type) {
if ($runtime = $this->getRuntime($type)) {
return $runtime;
}
}
return null;
}
}

View File

@ -18,7 +18,7 @@ namespace Symfony\Component\Runtime\Internal;
*/
class BasicErrorHandler
{
public function __construct(bool $debug)
public static function register(bool $debug): void
{
error_reporting(-1);
@ -32,10 +32,11 @@ class BasicErrorHandler
if (0 <= ini_get('zend.assertions')) {
ini_set('zend.assertions', 1);
ini_set('assert.active', $debug);
ini_set('assert.bail', 0);
ini_set('assert.warning', 0);
ini_set('assert.exception', 1);
}
set_error_handler(new self());
}
public function __invoke(int $type, string $message, string $file, int $line): bool

View File

@ -102,14 +102,12 @@ class ComposerPlugin implements PluginInterface, EventSubscriberInterface
throw new \InvalidArgumentException(sprintf('Class "%s" listed under "extra.runtime.class" in your composer.json file '.(class_exists($runtimeClass) ? 'should implement "%s".' : 'not found.'), $runtimeClass, RuntimeInterface::class));
}
if (!\is_array($runtimeOptions = $extra['options'] ?? [])) {
throw new \InvalidArgumentException('The "extra.runtime.options" entry in your composer.json file must be an array.');
}
unset($extra['class'], $extra['autoload_template']);
$code = strtr(file_get_contents($autoloadTemplate), [
'%project_dir%' => $projectDir,
'%runtime_class%' => var_export($runtimeClass, true),
'%runtime_options%' => '['.substr(var_export($runtimeOptions, true), 7, -1)." 'project_dir' => {$projectDir},\n]",
'%runtime_options%' => '['.substr(var_export($extra, true), 7, -1)." 'project_dir' => {$projectDir},\n]",
]);
file_put_contents(substr_replace($autoloadFile, '_runtime', -4, 0), $code);

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\Console;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class ApplicationRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\Console\Command;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class CommandRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\Console\Input;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class InputInterfaceRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\Console\Output;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class OutputInterfaceRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\HttpFoundation;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class RequestRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\HttpFoundation;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class ResponseRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Runtime\Symfony\Component\HttKernel;
use Symfony\Component\Runtime\SymfonyRuntime;
/**
* @internal
*/
class HttpKernelInterfaceRuntime extends SymfonyRuntime
{
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Runtime\Internal;
use Symfony\Component\ErrorHandler\BufferingLogger;
use Symfony\Component\ErrorHandler\DebugClassLoader;
use Symfony\Component\ErrorHandler\ErrorHandler;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class SymfonyErrorHandler
{
public static function register(bool $debug): void
{
BasicErrorHandler::register($debug);
if (class_exists(ErrorHandler::class)) {
DebugClassLoader::enable();
restore_error_handler();
ErrorHandler::register(new ErrorHandler(new BufferingLogger(), true));
}
}
}

View File

@ -25,7 +25,7 @@ interface RuntimeInterface
*
* The callable itself should return an object that represents the application to pass to the getRunner() method.
*/
public function getResolver(callable $callable): ResolverInterface;
public function getResolver(callable $callable, \ReflectionFunction $reflector = null): ResolverInterface;
/**
* Returns a callable that knows how to run the passed object and that returns its exit status as int.

View File

@ -18,38 +18,35 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Runtime\Internal\MissingDotenv;
use Symfony\Component\Runtime\Internal\SymfonyErrorHandler;
use Symfony\Component\Runtime\Runner\Symfony\ConsoleApplicationRunner;
use Symfony\Component\Runtime\Runner\Symfony\HttpKernelRunner;
use Symfony\Component\Runtime\Runner\Symfony\ResponseRunner;
// Help opcache.preload discover always-needed symbols
class_exists(ResponseRunner::class);
class_exists(HttpKernelRunner::class);
class_exists(MissingDotenv::class, false) || class_exists(Dotenv::class) || class_exists(MissingDotenv::class);
/**
* Knows the basic conventions to run Symfony apps.
*
* Accepts the following options:
* - "debug" to toggle debugging features
* - "env" to define the name of the environment the app runs in
* - "disable_dotenv" to disable looking for .env files
* - "dotenv_path" to define the path of dot-env files - defaults to ".env"
* - "prod_envs" to define the names of the production envs - defaults to ["prod"]
* - "test_envs" to define the names of the test envs - defaults to ["test"]
* In addition to the options managed by GenericRuntime, it accepts the following options:
* - "env" to define the name of the environment the app runs in;
* - "disable_dotenv" to disable looking for .env files;
* - "dotenv_path" to define the path of dot-env files - defaults to ".env";
* - "prod_envs" to define the names of the production envs - defaults to ["prod"];
* - "test_envs" to define the names of the test envs - defaults to ["test"];
* - "use_putenv" to tell Dotenv to set env vars using putenv() (NOT RECOMMENDED.)
*
* When these options are not defined, they will fallback:
* - to reading the "APP_DEBUG" and "APP_ENV" environment variables;
* - to parsing the "--env|-e" and "--no-debug" command line arguments
* if the "symfony/console" component is installed.
* When the "debug" / "env" options are not defined, they will fallback to the
* "APP_DEBUG" / "APP_ENV" environment variables, and to the "--env|-e" / "--no-debug"
* command line arguments if "symfony/console" is installed.
*
* When the "symfony/dotenv" component is installed, .env files are loaded.
* When "symfony/error-handler" is installed, it is registred in debug mode.
* When "symfony/error-handler" is installed, it is registered in debug mode.
*
* On top of the base arguments provided by GenericRuntime,
* this runtime can feed the app-callable with arguments of type:
@ -74,7 +71,6 @@ class SymfonyRuntime extends GenericRuntime
private $output;
private $console;
private $command;
private $env;
/**
* @param array {
@ -85,31 +81,32 @@ class SymfonyRuntime extends GenericRuntime
* prod_envs?: ?string[],
* dotenv_path?: ?string,
* test_envs?: ?string[],
* use_putenv?: ?bool,
* runtimes?: ?array,
* error_handler?: string|false,
* } $options
*/
public function __construct(array $options = [])
{
$this->env = $options['env'] ?? null;
$_SERVER['APP_ENV'] = $options['env'] ?? $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null;
if (isset($_SERVER['argv']) && null === $this->env && class_exists(ArgvInput::class)) {
if (isset($_SERVER['argv']) && !isset($options['env']) && class_exists(ArgvInput::class)) {
$this->options = $options;
$this->getInput();
}
$_SERVER['APP_ENV'] = $options['env'] ?? $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? 'dev';
if (!($options['disable_dotenv'] ?? false) && isset($options['project_dir']) && !class_exists(MissingDotenv::class, false)) {
(new Dotenv())
->setProdEnvs((array) ($options['prod_envs'] ?? ['prod']))
->usePutenv($options['use_putenv'] ?? false)
->bootEnv($options['project_dir'].'/'.($options['dotenv_path'] ?? '.env'), 'dev', (array) ($options['test_envs'] ?? ['test']));
$options['debug'] ?? $options['debug'] = '1' === $_SERVER['APP_DEBUG'];
$options['disable_dotenv'] = true;
}
$options['error_handler'] ?? $options['error_handler'] = SymfonyErrorHandler::class;
parent::__construct($options);
if ($_SERVER['APP_DEBUG'] && class_exists(Debug::class)) {
restore_error_handler();
umask(0000);
Debug::enable();
}
}
public function getRunner(?object $application): RunnerInterface
@ -142,7 +139,7 @@ class SymfonyRuntime extends GenericRuntime
}
set_time_limit(0);
$defaultEnv = null === $this->env ? ($_SERVER['APP_ENV'] ?? 'dev') : null;
$defaultEnv = !isset($this->options['env']) ? ($_SERVER['APP_ENV'] ?? 'dev') : null;
$output = $this->output ?? $this->output = new ConsoleOutput();
return new ConsoleApplicationRunner($application, $defaultEnv, $this->getInput(), $output);
@ -180,6 +177,23 @@ class SymfonyRuntime extends GenericRuntime
return parent::getArgument($parameter, $type);
}
protected static function register(parent $runtime): parent
{
$self = new self($runtime->options + ['runtimes' => []]);
$self->options['runtimes'] += [
HttpKernelInterface::class => $self,
Request::class => $self,
Response::class => $self,
Application::class => $self,
Command::class => $self,
InputInterface::class => $self,
OutputInterface::class => $self,
];
$runtime->options = $self->options;
return $self;
}
private function getInput(): ArgvInput
{
if (null !== $this->input) {
@ -188,7 +202,7 @@ class SymfonyRuntime extends GenericRuntime
$input = new ArgvInput();
if (null !== $this->env) {
if (isset($this->options['env'])) {
return $this->input = $input;
}

View File

@ -12,7 +12,8 @@ if (file_exists(dirname(__DIR__, 2).'/vendor/autoload.php')) {
}
$app = require $_SERVER['SCRIPT_FILENAME'];
$runtime = new SymfonyRuntime($_SERVER['APP_RUNTIME_OPTIONS']);
$runtime = $_SERVER['APP_RUNTIME'] ?? SymfonyRuntime::class;
$runtime = new $runtime($_SERVER['APP_RUNTIME_OPTIONS']);
[$app, $args] = $runtime->getResolver($app)->resolve();
exit($runtime->getRunner($app(...$args))->run());
}

View File

@ -0,0 +1,19 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Runtime\GenericRuntime;
use Symfony\Runtime\Symfony\Component\HttpFoundation\RequestRuntime;
use Symfony\Runtime\Symfony\Component\HttpFoundation\ResponseRuntime;
$_SERVER['APP_RUNTIME'] = GenericRuntime::class;
require __DIR__.'/autoload.php';
return function (Request $request, array $context) {
echo class_exists(RequestRuntime::class, false) ? 'OK request runtime' : 'KO request runtime', "\n";
return new StreamedResponse(function () use ($context) {
echo 'OK Request '.$context['SOME_VAR'], "\n";
echo class_exists(ResponseRuntime::class, false) ? 'KO response runtime' : 'OK response runtime', "\n";
});
};

View File

@ -0,0 +1,14 @@
--TEST--
Test Request/Response
--INI--
display_errors=1
--FILE--
<?php
require $_SERVER['SCRIPT_FILENAME'] = __DIR__.'/generic-request.php';
?>
--EXPECTF--
OK request runtime
OK Request foo_bar
OK response runtime

View File

@ -1,9 +1,9 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Runtime\SymfonyRuntime;
use Symfony\Component\Runtime\Runner\ClosureRunner;
use Symfony\Component\Runtime\RunnerInterface;
use Symfony\Component\Runtime\SymfonyRuntime;
require __DIR__.'/autoload.php';

View File

@ -30,7 +30,10 @@
"symfony/dotenv": "<5.1"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Runtime\\": "" },
"psr-4": {
"Symfony\\Component\\Runtime\\": "",
"Symfony\\Runtime\\Symfony\\Component\\": "Internal/"
},
"exclude-from-classmap": [
"/Tests/"
]