-
-
Notifications
You must be signed in to change notification settings - Fork 825
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
AutoService - Automatically add services to the container based on fi…
…le-scan
- Loading branch information
Showing
7 changed files
with
778 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
<?php | ||
|
||
namespace Civi\Core\Compiler; | ||
|
||
use Civi\Core\ClassScanner; | ||
use Civi\Core\Service\AutoServiceInterface; | ||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; | ||
use Symfony\Component\DependencyInjection\ContainerBuilder; | ||
|
||
/** | ||
* Scan the source-tree for implementations of `AutoServiceInterface`. Load them. | ||
* | ||
* Note: This will scan the core codebase as well as active extensions. For fully automatic | ||
* support in an extension, the extension must enable the mixin `scan-classes@1`. | ||
*/ | ||
class AutoServiceScannerPass implements CompilerPassInterface { | ||
|
||
public function process(ContainerBuilder $container) { | ||
$autoServices = ClassScanner::get(['interface' => AutoServiceInterface::class]); | ||
foreach ($autoServices as $autoService) { | ||
$autoService::buildContainer($container); | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
<?php | ||
|
||
namespace Civi\Core\Service; | ||
|
||
use Civi\Api4\Utils\ReflectionUtils; | ||
use Civi\Core\HookInterface; | ||
use Symfony\Component\DependencyInjection\Definition; | ||
use Symfony\Component\DependencyInjection\Reference; | ||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
|
||
class AutoDefinition { | ||
|
||
/** | ||
* Identify any/all service-definitions for the given class. | ||
* | ||
* If the class defines any static factory methods, then there may be multiple definitions. | ||
* | ||
* @param string $className | ||
* The name of the class to scan. Look for `@inject` and `@service` annotations. | ||
* @return \Symfony\Component\DependencyInjection\Definition[] | ||
* Ex: ['my.service' => 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() | ||
)); | ||
} | ||
} | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
<?php | ||
/* | ||
+--------------------------------------------------------------------+ | ||
| Copyright CiviCRM LLC. All rights reserved. | | ||
| | | ||
| This work is published under the GNU AGPLv3 license with some | | ||
| permitted exceptions and without any warranty. For full license | | ||
| and copyright information, see https://civicrm.org/licensing | | ||
+--------------------------------------------------------------------+ | ||
*/ | ||
namespace Civi\Core\Service; | ||
|
||
/** | ||
* AutoService is a base-class for defining a service (in the service-container). | ||
* Classes which extend AutoService will have these characteristics: | ||
* | ||
* - The class is scanned automatically (if you enable `scan-classes@1`). | ||
* - The class is auto-registered as a service in Civi's container. | ||
* - The service is given a default name (derived from the class name). | ||
* - The service may subscribe to events (via `HookInterface` or `EventSubscriberInterface`). | ||
* | ||
* Additionally, the class will be scanned for various annotations: | ||
* | ||
* - Class annotations: | ||
* - `@service <service.name>`: Customize the service name. | ||
* - `@serviceTags <tag1,tag2>`: Declare additional tags for the service. | ||
* - Property annotations | ||
* - `@inject [<service.name>]`: Inject another service automatically (by assigning this property). | ||
* If the '<service.name>' is blank, then it loads an eponymous service. | ||
* - Method annotations | ||
* - (TODO) `@inject <service.name>`: 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; | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
<?php | ||
/* | ||
+--------------------------------------------------------------------+ | ||
| Copyright CiviCRM LLC. All rights reserved. | | ||
| | | ||
| This work is published under the GNU AGPLv3 license with some | | ||
| permitted exceptions and without any warranty. For full license | | ||
| and copyright information, see https://civicrm.org/licensing | | ||
+--------------------------------------------------------------------+ | ||
*/ | ||
namespace Civi\Core\Service; | ||
|
||
use Symfony\Component\DependencyInjection\ContainerBuilder; | ||
|
||
/** | ||
* The CiviCRM container will automatically load classes that implement | ||
* AutoServiceInterface. | ||
* | ||
* Formally, this resembles `hook_container` and `CompilerPassInterface`. However, the | ||
* build method must be `static` (running before CiviCRM has fully booted), and downstream | ||
* implementations should generally register concrete services (rather performing meta-services | ||
* like tag-evaluation). | ||
*/ | ||
interface AutoServiceInterface { | ||
|
||
/** | ||
* Register any services with the container. | ||
*/ | ||
public static function buildContainer(ContainerBuilder $container): void; | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
<?php | ||
/* | ||
+--------------------------------------------------------------------+ | ||
| Copyright CiviCRM LLC. All rights reserved. | | ||
| | | ||
| This work is published under the GNU AGPLv3 license with some | | ||
| permitted exceptions and without any warranty. For full license | | ||
| and copyright information, see https://civicrm.org/licensing | | ||
+--------------------------------------------------------------------+ | ||
*/ | ||
namespace Civi\Core\Service; | ||
|
||
use Symfony\Component\DependencyInjection\ContainerBuilder; | ||
|
||
/** | ||
* By combining AutoServiceInterface and AutoServiceTrait, you can make any class | ||
* behave like an AutoService (auto-registered in the CiviCRM container). | ||
*/ | ||
trait AutoServiceTrait { | ||
|
||
/** | ||
* Register the service in the container. | ||
* | ||
* @param \Symfony\Component\DependencyInjection\ContainerBuilder $container | ||
* @internal | ||
*/ | ||
final public static function buildContainer(ContainerBuilder $container): void { | ||
// "final": AutoServices should avoid coupling to Symfony DI. However, if you really | ||
// need to customize this, then omit AutoServiceTrait and write your own variant. | ||
|
||
$file = (new \ReflectionClass(static::class))->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; | ||
} | ||
|
||
} |
Oops, something went wrong.