From eb92dd792c07e0b11ee1561cf00930402345e8b3 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 15 Aug 2022 16:55:53 -0700 Subject: [PATCH] AutoService - Automatically add services to the container based on file-scan --- Civi/Core/Compiler/AutoServiceScannerPass.php | 25 + Civi/Core/Container.php | 3 + Civi/Core/Service/AutoDefinition.php | 195 ++++++++ Civi/Core/Service/AutoService.php | 41 ++ Civi/Core/Service/AutoServiceInterface.php | 31 ++ Civi/Core/Service/AutoServiceTrait.php | 51 +++ .../Civi/Core/Service/AutoDefinitionTest.php | 432 ++++++++++++++++++ 7 files changed, 778 insertions(+) create mode 100644 Civi/Core/Compiler/AutoServiceScannerPass.php create mode 100644 Civi/Core/Service/AutoDefinition.php create mode 100644 Civi/Core/Service/AutoService.php create mode 100644 Civi/Core/Service/AutoServiceInterface.php create mode 100644 Civi/Core/Service/AutoServiceTrait.php create mode 100644 tests/phpunit/Civi/Core/Service/AutoDefinitionTest.php diff --git a/Civi/Core/Compiler/AutoServiceScannerPass.php b/Civi/Core/Compiler/AutoServiceScannerPass.php new file mode 100644 index 0000000000..957a50ef2d --- /dev/null +++ b/Civi/Core/Compiler/AutoServiceScannerPass.php @@ -0,0 +1,25 @@ + AutoServiceInterface::class]); + foreach ($autoServices as $autoService) { + $autoService::buildContainer($container); + } + } + +} diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 4f89170fcc..8398460611 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -1,11 +1,13 @@ addCompilerPass(new AutoServiceScannerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 1000); $container->addCompilerPass(new EventScannerPass()); $container->addCompilerPass(new SpecProviderPass()); $container->addCompilerPass(new RegisterListenersPass()); diff --git a/Civi/Core/Service/AutoDefinition.php b/Civi/Core/Service/AutoDefinition.php new file mode 100644 index 0000000000..524fa6c4b1 --- /dev/null +++ b/Civi/Core/Service/AutoDefinition.php @@ -0,0 +1,195 @@ + new Definition('My\Class')] + */ + public static function scan(string $className): array { + $class = new \ReflectionClass($className); + $result = []; + + $classDoc = ReflectionUtils::parseDocBlock($class->getDocComment()); + if (!empty($classDoc['service'])) { + $serviceName = static::pickName($classDoc, $class->getName()); + $def = static::createBaseline($class, $classDoc); + self::applyConstructor($def, $class); + $result[$serviceName] = $def; + } + + foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_STATIC) as $method) { + /** @var \ReflectionMethod $method */ + $methodDoc = ReflectionUtils::parseDocBlock($method->getDocComment()); + if (!empty($methodDoc['service'])) { + $serviceName = static::pickName($methodDoc, $class->getName() . '::' . $method->getName()); + $returnClass = isset($methodDoc['return'][0]) ? new \ReflectionClass($methodDoc['return'][0]) : $class; + $def = static::createBaseline($returnClass, $methodDoc); + $def->setFactory($class->getName() . '::' . $method->getName()); + $def->setArguments(static::toReferences($methodDoc['inject'] ?? '')); + $result[$serviceName] = $def; + } + } + + if (count($result) === 0) { + error_log("WARNING: Class {$class->getName()} was expected to have a service definition, but it did not. Perhaps it needs service name."); + } + + return $result; + } + + /** + * Create a basic definition for an unnamed service. + * + * @param string $className + * The name of the class to scan. Look for `@inject` and `@service` annotations. + * @return \Symfony\Component\DependencyInjection\Definition + */ + public static function create(string $className): Definition { + $class = new \ReflectionClass($className); + $classDoc = ReflectionUtils::parseDocBlock($class->getDocComment()); + $def = static::createBaseline($class, $classDoc); + static::applyConstructor($def, $class); + return $def; + } + + protected static function pickName(array $docBlock, string $internalDefault): string { + if (is_string($docBlock['service'])) { + return $docBlock['service']; + } + if (!empty($docBlock['internal']) && $internalDefault) { + return $internalDefault; + } + throw new \RuntimeException("Error: Failed to determine service name ($internalDefault). Please specify '@service NAME' or '@internal'."); + } + + protected static function createBaseline(\ReflectionClass $class, ?array $docBlock = []): Definition { + $class = is_string($class) ? new \ReflectionClass($class) : $class; + $def = new Definition($class->getName()); + $def->setPublic(TRUE); + self::applyTags($def, $class, $docBlock); + self::applyObjectProperties($def, $class); + self::applyObjectMethods($def, $class); + return $def; + } + + protected static function toReferences(string $injectExpr): array { + return array_map( + function (string $part) { + return new Reference($part); + }, + static::splitSymbols($injectExpr) + ); + } + + protected static function splitSymbols(string $expr): array { + if ($expr === '') { + return []; + } + $extraTags = explode(',', $expr); + return array_map('trim', $extraTags); + } + + /** + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + * @param array $docBlock + */ + protected static function applyTags(Definition $def, \ReflectionClass $class, array $docBlock): void { + if (!empty($docBlock['internal'])) { + $def->addTag('internal'); + } + if ($class->implementsInterface(HookInterface::class) || $class->implementsInterface(EventSubscriberInterface::class)) { + $def->addTag('event_subscriber'); + } + + if (!empty($classDoc['serviceTags'])) { + foreach (static::splitSymbols($classDoc['serviceTags']) as $extraTag) { + $def->addTag($extraTag); + } + } + } + + /** + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + */ + protected static function applyConstructor(Definition $def, \ReflectionClass $class): void { + if ($construct = $class->getConstructor()) { + $constructAnno = ReflectionUtils::parseDocBlock($construct->getDocComment() ?? ''); + if (!empty($constructAnno['inject'])) { + $def->setArguments(static::toReferences($constructAnno['inject'])); + } + } + } + + /** + * Scan for any methods with `@inject`. They should be invoked via `$def->addMethodCall()`. + * + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + */ + protected static function applyObjectMethods(Definition $def, \ReflectionClass $class): void { + foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + /** @var \ReflectionMethod $method */ + if ($method->isStatic()) { + continue; + } + + $anno = ReflectionUtils::parseDocBlock($method->getDocComment()); + if (!empty($anno['inject'])) { + $def->addMethodCall($method->getName(), static::toReferences($anno['inject'])); + } + } + } + + /** + * Scan for any properties with `@inject`. They should be configured via `$def->setProperty()` + * or via `injectPrivateProperty()`. + * + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + * @throws \Exception + */ + protected static function applyObjectProperties(Definition $def, \ReflectionClass $class): void { + foreach ($class->getProperties() as $property) { + /** @var \ReflectionProperty $property */ + if ($property->isStatic()) { + continue; + } + + $propDoc = ReflectionUtils::getCodeDocs($property); + if (!empty($propDoc['inject'])) { + if ($propDoc['inject'] === TRUE) { + $propDoc['inject'] = $property->getName(); + } + if ($property->isPublic()) { + $def->setProperty($property->getName(), new Reference($propDoc['inject'])); + } + elseif ($class->hasMethod('injectPrivateProperty')) { + $def->addMethodCall('injectPrivateProperty', [$property->getName(), new Reference($propDoc['inject'])]); + } + else { + throw new \Exception(sprintf('Property %s::$%s is marked private. To inject services into private properties, you must implement method "injectPrivateProperty($key, $value)".', + $class->getName(), $property->getName() + )); + } + } + } + } + +} diff --git a/Civi/Core/Service/AutoService.php b/Civi/Core/Service/AutoService.php new file mode 100644 index 0000000000..cc0a3a5496 --- /dev/null +++ b/Civi/Core/Service/AutoService.php @@ -0,0 +1,41 @@ +`: Customize the service name. + * - `@serviceTags `: Declare additional tags for the service. + * - Property annotations + * - `@inject []`: Inject another service automatically (by assigning this property). + * If the '' is blank, then it loads an eponymous service. + * - Method annotations + * - (TODO) `@inject `: Inject another service automatically (by calling the setter-method). + * + * Note: Like other services in the container, AutoService cannot meaningfully subscribe to + * early/boot-critical events such as `hook_entityTypes` or `hook_container`. However, you may + * get a similar effect by customizing the `buildContainer()` method. + */ +abstract class AutoService implements AutoServiceInterface { + + use AutoServiceTrait; + +} diff --git a/Civi/Core/Service/AutoServiceInterface.php b/Civi/Core/Service/AutoServiceInterface.php new file mode 100644 index 0000000000..b1319e975e --- /dev/null +++ b/Civi/Core/Service/AutoServiceInterface.php @@ -0,0 +1,31 @@ +getFileName(); + $container->addResource(new \Symfony\Component\Config\Resource\FileResource($file)); + foreach (AutoDefinition::scan(static::class) as $id => $definition) { + $container->setDefinition($id, $definition); + } + } + + /** + * (Internal) Utility method used to `@inject` data into private properties. + * + * @param string $key + * @param mixed $value + * @internal + */ + final public function injectPrivateProperty(string $key, $value): void { + // "final": There is no need to override. If you want a custom assignment logic, then put `@inject` on your setter method. + + $this->{$key} = $value; + } + +} diff --git a/tests/phpunit/Civi/Core/Service/AutoDefinitionTest.php b/tests/phpunit/Civi/Core/Service/AutoDefinitionTest.php new file mode 100644 index 0000000000..e7985eba77 --- /dev/null +++ b/tests/phpunit/Civi/Core/Service/AutoDefinitionTest.php @@ -0,0 +1,432 @@ +useTransaction(); + } + + /** + * A property with the `@inject` annotation will receive a serivce with the matching name. + */ + public function testInjectEponymousProperty() { + $this->useExampleService( + /** + * @service TestEponymousProperty + */ + new class() { + + /** + * @var \Psr\Log\LoggerInterface + * @inject + */ + public $psr_log; + + } + ); + + $instance = \Civi::service('TestEponymousProperty'); + $this->assertInstanceOf(LoggerInterface::class, $instance->psr_log); + } + + /** + * A property with the `@inject` annotation can be private. + */ + public function testInjectPrivateProperty() { + $this->useExampleService( + /** + * @service TestInjectPrivateProperty + */ + new class() { + + use AutoServiceTrait; + + /** + * @var \Psr\Log\LoggerInterface + * @inject + */ + private $psr_log; + + } + ); + + $instance = \Civi::service('TestInjectPrivateProperty'); + $this->assertInstanceOf(LoggerInterface::class, Invasive::get([$instance, 'psr_log'])); + } + + /** + * A property with `@inject ` will receive the named service. + */ + public function testInjectNamedProperty() { + $this->useExampleService( + /** + * @service TestNamedProperty + */ + new class() { + + /** + * @var \Psr\Log\LoggerInterface + * @inject cache.extension_browser + */ + public $cache; + + } + ); + + $instance = \Civi::service('TestNamedProperty'); + $this->assertInstanceOf(\CRM_Utils_Cache_SqlGroup::class, $instance->cache); + $this->assertEquals('extension_browser', Invasive::get([$instance->cache, 'group'])); + } + + /** + * A method `setFooBar()` with `@inject ` will be called during initialization + * with the requested service. + */ + public function testInjectSetter() { + $this->useExampleService( + /** + * @service TestInjectSetter + */ + new class() { + + /** + * @var \Psr\Log\LoggerInterface + */ + private $log; + + /** + * @return \Psr\Log\LoggerInterface|null + */ + public function getLog(): ?\Psr\Log\LoggerInterface { + return $this->log; + } + + /** + * @param \Psr\Log\LoggerInterface|null $log + * @inject psr_log + */ + public function setLog(?\Psr\Log\LoggerInterface $log): void { + $this->log = $log; + } + + } + ); + + $instance = \Civi::service('TestInjectSetter'); + $this->assertInstanceOf(LoggerInterface::class, $instance->getLog()); + } + + /** + * A constructor with `@inject ` will be called with the requested service. + */ + public function testInjectConstructor() { + $this->useExampleService( + /** + * @service TestInjectConstructor + */ + new class() { + + /** + * @var \Psr\Log\LoggerInterface + */ + private $log; + + /** + * @var \Psr\SimpleCache\CacheInterface + */ + private $cache; + + /** + * @param \Psr\Log\LoggerInterface|null $log + * @param \Psr\SimpleCache\CacheInterface|null $cache + * @inject psr_log, cache.extension_browser + */ + public function __construct(?\Psr\Log\LoggerInterface $log = NULL, ?\Psr\SimpleCache\CacheInterface $cache = NULL) { + $this->log = $log; + $this->cache = $cache; + } + + } + ); + + $instance = \Civi::service('TestInjectConstructor'); + $this->assertInstanceOf(LoggerInterface::class, Invasive::get([$instance, 'log'])); + $this->assertInstanceOf(\CRM_Utils_Cache_SqlGroup::class, Invasive::get([$instance, 'cache'])); + $this->assertEquals('extension_browser', Invasive::get([Invasive::get([$instance, 'cache']), 'group'])); + } + + /** + * If you use `@inject` on multiple items, the sequence of injections should be deterministic. + * + * Note, however, that upstream doesn't guarantee the sequence over the long-term. + * If it changes, you may need to update the test. + */ + public function testInjectionSequence() { + $this->useExampleService( + /** + * @service TestInjectionSequence + */ + new class() { + + use AutoServiceTrait; + + /** + * A list of snapshots -- at each point in time, what fields have been defined? + * + * @var array + */ + public $sequence = []; + + /** + * @var \Psr\SimpleCache\CacheInterface + */ + private $asConstructorArg; + + /** + * @var \Psr\SimpleCache\CacheInterface + * @inject cache.default + */ + private $asPrivateProperty; + + /** + * @var \Psr\SimpleCache\CacheInterface + * @inject cache.metadata + */ + public $asPublicProperty; + + /** + * @var \Psr\SimpleCache\CacheInterface + */ + private $asSetterMethod; + + /** + * @param \Psr\SimpleCache\CacheInterface|null $asConstructorArg + * @inject cache.long + */ + public function __construct(?\Psr\SimpleCache\CacheInterface $asConstructorArg = NULL) { + $this->asConstructorArg = $asConstructorArg; + $this->sequence[] = ['@' . __FUNCTION__, $this->getFilledFields()]; + } + + /** + * @param \Psr\SimpleCache\CacheInterface $asSetterMethod + * @inject cache.js_strings + */ + public function setAsSetterMethod($asSetterMethod): void { + $this->asSetterMethod = $asSetterMethod; + $this->sequence[] = ['@' . __FUNCTION__, $this->getFilledFields()]; + } + + public function getFilledFields(): array { + $actualNames = []; + foreach (['asConstructorArg', 'asPrivateProperty', 'asPublicProperty', 'asSetterMethod'] as $name) { + if (!empty($this->{$name})) { + $actualNames[] = $name; + } + } + return $actualNames; + } + + } + ); + + $instance = \Civi::service('TestInjectionSequence'); + $expectedSequence = [ + // ['@functionWhichTookSnapshot', ['list', 'of', 'filled', 'fields']] + 0 => ['@__construct', ['asConstructorArg']], + 1 => ['@__construct', ['asConstructorArg', 'asPrivateProperty', 'asPublicProperty']], + // ^^ Ugh, when mixing injectors, Symfony calls the constructor twice... + 2 => ['@setAsSetterMethod', ['asConstructorArg', 'asPrivateProperty', 'asPublicProperty', 'asSetterMethod']], + ]; + $this->assertEquals($expectedSequence, $instance->sequence); + } + + /** + * The `@service` annotation can be used to define factory methods. + * + * In this example, we create two services (each with a different factory method, and each + * with a different kind of data). + */ + public function testFactoryMethods() { + $this->useExampleService( + new class() { + + /** + * @var \Psr\Log\LoggerInterface + */ + private $log; + + /** + * A factory that returns an instance of this class. + * + * @service TestInjectFactory.self + * @inject psr_log + * @param \Psr\Log\LoggerInterface|null $log + */ + public static function selfFactory(?\Psr\Log\LoggerInterface $log = NULL) { + $self = new static(); + $self->log = $log; + return $self; + } + + /** + * A factory that returns picks a dynamic class. + * + * @service TestInjectFactory.dynamic + * @inject psr_log + * @return \Psr\SimpleCache\CacheInterface + * The concrete type will depend on configuration. + */ + public static function dynamicFactory(?\Psr\Log\LoggerInterface $log = NULL) { + if (!($log instanceof LoggerInterface)) { + throw new \RuntimeException('Expected to get a log'); + } + return \CRM_Utils_Cache::create([ + 'type' => ['*memory*', 'ArrayCache'], + 'name' => 'yourFactory', + ]); + } + + } + ); + + $instance = \Civi::service('TestInjectFactory.self'); + $this->assertInstanceOf(LoggerInterface::class, Invasive::get([$instance, 'log'])); + $this->assertInstanceOf(CacheInterface::class, \Civi::service('TestInjectFactory.dynamic')); + } + + /** + * What happens if you have multiple `@service` definitions (one on the class, one on a factory-method)? + * You get multiple services. + */ + public function testClassAndFactoryMix() { + $this->useExampleService( + /** + * @service TestClassAndFactoryMix.normal + */ + new class() { + + /** + * @var \Psr\Log\LoggerInterface + */ + private $log; + + /** + * A factory that returns an instance of this class. + * + * @inject psr_log + * @param \Psr\Log\LoggerInterface|null $log + */ + public function __construct(?\Psr\Log\LoggerInterface $log = NULL) { + $this->log = $log; + } + + /** + * A factory that returns picks a dynamic class. + * + * @service TestClassAndFactoryMix.dynamic + * @inject psr_log + * @return \Psr\SimpleCache\CacheInterface + * The concrete type will depend on configuration. + */ + public static function dynamicFactory(?\Psr\Log\LoggerInterface $log = NULL) { + if (!($log instanceof LoggerInterface)) { + throw new \RuntimeException('Expected to get a log'); + } + return \CRM_Utils_Cache::create([ + 'type' => ['*memory*', 'ArrayCache'], + 'name' => 'yourFactory', + ]); + } + + } + ); + + $instance = \Civi::service('TestClassAndFactoryMix.normal'); + $this->assertInstanceOf(LoggerInterface::class, Invasive::get([$instance, 'log'])); + $this->assertInstanceOf(CacheInterface::class, \Civi::service('TestClassAndFactoryMix.dynamic')); + } + + /** + * It is possible for third-party code to use `AutoDefinition` as the starting-point for + * their own (slightly customized) service definitions. + * + * In this example, we make two instances. Each instance has a different value for `$myName`. + */ + public function testTwoManualServices() { + $this->useCustomContainer(function(ContainerBuilder $container) { + $exemplar = new class() implements AutoServiceInterface { + + public static function buildContainer(ContainerBuilder $container): void { + $container->setDefinition('TestTwoManualServices.1', AutoDefinition::create(static::class) + ->setProperty('myName', 'first')); + $container->setDefinition('TestTwoManualServices.2', AutoDefinition::create(static::class) + ->setProperty('myName', 'second')); + } + + /** + * @var string + */ + public $myName; + + /** + * @var \Psr\Log\LoggerInterface + * @inject psr_log + */ + public $log; + + }; + $exemplar::buildContainer($container); + }); + + $first = \Civi::service('TestTwoManualServices.1'); + $this->assertEquals('first', $first->myName); + $this->assertInstanceOf(LoggerInterface::class, $first->log); + + $second = \Civi::service('TestTwoManualServices.2'); + $this->assertEquals('second', $second->myName); + $this->assertInstanceOf(LoggerInterface::class, $second->log); + } + + protected function useExampleService($exemplar) { + $this->useCustomContainer(function(ContainerBuilder $container) use ($exemplar) { + $definitions = AutoDefinition::scan(get_class($exemplar)); + $container->addDefinitions($definitions); + }); + } + + protected function useCustomContainer(callable $callback) { + $container = (new Container())->createContainer(); + $callback($container); + $container->compile(); + Container::useContainer($container); + } + +} -- 2.25.1